diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index ff520a88d25..92ee4e1fbd9 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -3323,9 +3323,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "once_cell", "ring", diff --git a/devlog/_fin/260915_workflow_budget_window/030_wfc_diff_plan.md b/devlog/_fin/260915_workflow_budget_window/030_wfc_diff_plan.md index 7b4617b716a..b2d2c7b6905 100644 --- a/devlog/_fin/260915_workflow_budget_window/030_wfc_diff_plan.md +++ b/devlog/_fin/260915_workflow_budget_window/030_wfc_diff_plan.md @@ -96,6 +96,13 @@ declared `deferred-verb` in the route registry against this document, which is what keeps them out of the undeclared-route ratchet without pretending the gap does not exist. +The later configurable-ceilings change also defers the live CLI counterpart of +`PUT /api/workflow-budget/settings` against this document. Its management API +updates the running policy; local `ocx config set/unset workflowBudget...` +edits disk configuration and requires a proxy restart for live convergence. +`GET /api/workflow-budget/settings` is a `local-transport` exemption because +`ocx config get` can read the same configuration block locally. + ## Verification posture Local suite, typecheck, install and build: NOT RUN, by standing instruction. diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index 85650d22e13..6b7439f0b0c 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -199,9 +199,9 @@ configuré sous [`claudeCode.authMode`](/fr/reference/configuration/server/#clau ### Ce qu'une sauvegarde de fournisseur conserve -`POST /api/providers` avec le nom d'un fournisseur existant remplace la ligne enregistrée par une ligne construite à partir de la requête. Le formulaire d'ajout/modification du tableau de bord ne peut pas envoyer tous les champs ; la sauvegarde conserve donc certains champs enregistrés que la requête omet. Dix d'entre eux décrivent le comportement d'un amont précis : `preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`. +`POST /api/providers` avec le nom d'un fournisseur existant remplace la ligne enregistrée par une ligne construite à partir de la requête. Le formulaire d'ajout/modification du tableau de bord ne peut pas envoyer tous les champs ; la sauvegarde conserve donc certains champs enregistrés que la requête omet. Onze d'entre eux décrivent le comportement d'un amont précis : `preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `promoteAdditionalTools`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`. -| Sauvegarde | Les dix réglages | `apiKeyPool` enregistré | +| Sauvegarde | Les onze réglages | `apiKeyPool` enregistré | | --- | --- | --- | | Même destination, champ omis | Valeur enregistrée conservée, y compris un `[]` ou un `false` explicite | Conservé | | Nouvelle destination, champ omis | Non conservée ; les valeurs par défaut du registre pour la nouvelle destination peuvent s'appliquer | Non conservé | diff --git a/docs-site/src/content/docs/guides/chatgpt-desktop.md b/docs-site/src/content/docs/guides/chatgpt-desktop.md index 458acdfbedf..8f195bfa829 100644 --- a/docs-site/src/content/docs/guides/chatgpt-desktop.md +++ b/docs-site/src/content/docs/guides/chatgpt-desktop.md @@ -104,10 +104,11 @@ restart transaction. ## Failure behavior and known limits -When the platform is not macOS, the OpenCodex runtime is missing, or the filter -self-test fails, the launcher runs the original binary with untouched stdout. A -missing bundled app-server binary is the exception: there is nothing to fall back -to, so the launcher exits with an error (see below). +The launcher verifies the executable before every start, including Desktop respawns. +If stdout filtering is unavailable or its self-test fails, the verified executable +runs with untouched stdout. If executable verification fails or the OpenCodex +validator is unavailable, the launcher exits with an error without starting the +app-server. A missing bundled app-server binary also prevents startup (see below). A filter that passes the self-test and then dies mid-session closes the pipe. What the bundled app-server does after that has not been verified; it may get SIGPIPE or a write error and be respawned by Desktop through the same launcher. @@ -117,8 +118,9 @@ remaining stream to raw bytes. The experiment depends on the bundled binary path, the app honoring `CODEX_CLI_PATH`, and current RPC field shapes. Updates may change these. A moved -or removed OpenCodex installation fails the launcher preflight and runs the -original binary. Run `ocx chatgpt launch` again after relocating the installation. +or removed OpenCodex installation can leave the launcher unable to verify the +executable, so it refuses startup. Run `ocx chatgpt launch` again after relocating +the installation, or `ocx chatgpt restore` to remove the override. If an app update moves or removes the bundled app-server binary itself, the launcher cannot start it: it prints a message naming `ocx chatgpt launch` and `ocx chatgpt restore` on stderr and exits, and Desktop cannot start its diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index bf0cf28d7e9..c0b83efa0ce 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -856,6 +856,11 @@ Proxy startup/ensure, `ocx claude`, and relevant dashboard saves sync your featu overwritten or pruned; your own agents are never touched. - Files are atomically synced per file (write + rename). - `enabled: false` or `injectAgents: false` prunes all verified-owned definitions. +- Turning Claude Code off with the dashboard's integration toggle also prunes them right away + (best effort), so a native `claude` session started afterwards stops seeing proxy-only agents. + A file that cannot be deleted stays, and while a sibling OpenCodex instance owns + `~/.claude/agents` nothing is touched; turning it off again retries and also removes + definitions an older version left behind. - Successful CLI first-party saves and persisted Desktop first-party setup also attempt best-effort roster sync, including repeated setup to repair missing definitions. Refused setup and rollback do not sync agents. Turning first-party off alone does not disable agent registration. diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index 57dc9ccb196..91ffeb34b0d 100644 --- a/docs-site/src/content/docs/guides/combos.md +++ b/docs-site/src/content/docs/guides/combos.md @@ -152,6 +152,13 @@ native routing and catalog identity on the next sync. ### Failover: ordered primary and backups +For Responses requests that require a tool, Meta Responses targets are skipped before sending +because Meta does not support mandatory `tool_choice`. An eligible later target receives the +original choice, subject to its existing adapter compatibility rules. This includes hosted choices +such as `web_search`, `tool_search`, `computer_use` and `image_gen`, and MCP choices with a +nonempty `server_label` and an omitted, null or nonempty name, including inside required allowed-tools lists. Chat translation preserves a forced `tool_search` as the synthetic function choice. If every target is incompatible for this reason, +the combo reports the constraint. Chat targets that cannot preserve a mandatory selector, including models configured for auto-only tool choice, are also skipped before sending. Registered OpenAI API virtual models use the actual wire model's adapter and tool-choice policy for this decision. Malformed or unknown choices retain the provider's ordinary error path. + `failover` selects the first eligible target in configuration order. A target is eligible when its provider exists, is enabled, is not cooling down, and can handle any special request constraint. Weights and `stickyLimit` do not affect this strategy. diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index d76818a097c..ae6a4775100 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -987,15 +987,40 @@ exists only while LazyCodex is detected. Without it the dashboard shows neither, Auto-assign proposes a model for every role at once. opencodex asks your default Codex model (the root `model` in Codex `config.toml`) one question: for each role, given its -description and the start of its instructions, which capability tier (fast, standard or frontier) -and how much reasoning (glance, measured, thorough or exhaustive) does it need? That model never -picks a model. opencodex then picks the cheapest model from your picker list that reaches the tier: +description and the start of its instructions, which capability tier (fast, standard or frontier), +how much reasoning (glance, measured, thorough or exhaustive) and which task domain (coding, agentic, +reasoning, writing, knowledge or general) does it need? That model never picks a model. opencodex +then picks the cheapest model from your picker list that reaches the tier: - Models listed under `codexRoleTiers` in the opencodex config (`{ "fast": [...], "standard": [...], "frontier": [...] }`) have that tier. +- When two or more of the other models have a published score in the role's domain, those models are + tiered by distinct score values, without requiring a price; equal scores share a tier, and unscored models are left out. Scores come from + [Artificial Analysis](https://artificialanalysis.ai) when you add your own API key + (below). The response's `capability` block names the source it used. - Other models with a known price are ranked by price and split evenly across the three tiers. With only one or two priced models, the dearest is frontier and the other, if any, is standard. -- Models with no price and no listed tier are never proposed. List them to include them. +- When tiering falls back to price, models with no price and no listed tier are not proposed. List them to include them. + +To use Artificial Analysis, create a free API key at [artificialanalysis.ai](https://artificialanalysis.ai/api-reference) +and set it as a literal, an `${ENV}` reference or a keychain reference: + +```json +{ + "codexRoleCapability": { + "artificialAnalysis": { "apiKey": "${ARTIFICIAL_ANALYSIS_API_KEY}" } + } +} +``` + +opencodex reads it once per day, which stays inside the free tier's daily request limit. Its terms allow +using the data for your own decisions and forbid passing it on, so auto-assign keeps the scores +internal: the response carries the tier each model got and the attribution, without the scores. If the +key is absent or the read fails, auto-assign uses configured tiers and prices; `capability.error` reports +why and the source attribution fields are null. A retired `huggingFace` setting is ignored. + +Artificial Analysis refuses HTTP redirects and limits each response body to 16 MiB with a 30-second +request timeout. Credentials are sent only to its fixed API endpoint. Each proposal shows the model, the tier, the reasoning effort, a one-line reason, and what would move it up or down. A role the model could not size clearly is shown as not sized, with the reason, and diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 4959c8a019b..5a43f759fe2 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -1082,6 +1082,10 @@ unchanged and were not probed in this update. > models. Coding Plan defaults to `ark-code-latest`, while Agent Plan defaults to > `deepseek-v4-flash`. +On the pay-as-you-go Ark route, Kimi K3.1 Pro (`kimi-k3-1-pro`, including dated versions) +replays tool-only assistant turns with an empty string. Its endpoint rejects the legacy +empty-text array with HTTP 400; other Ark models keep their existing continuation format. + > **Volcengine Plan usage restriction:** Volcengine documents Coding Plan and Agent Plan quota as > valid only inside supported AI coding tools, and warns that using a plan key for general API > calls may suspend the subscription or ban the account. Routing Codex or Claude Code through diff --git a/docs-site/src/content/docs/guides/sidecars.md b/docs-site/src/content/docs/guides/sidecars.md index 28b7955a74f..ed7f778add1 100644 --- a/docs-site/src/content/docs/guides/sidecars.md +++ b/docs-site/src/content/docs/guides/sidecars.md @@ -286,7 +286,7 @@ See the [Configuration reference](/reference/configuration/server/#sidecars) for Search responses with malformed optional fields retain valid answer text and citations. Invalid error-message values use a generic failure message. -The OpenAI search and vision sidecars read a final completion or failure event even when the server closes the body without a trailing newline. An unfinished response or one stopped by a size limit still reports an error. +The OpenAI search and vision sidecars accept CR, LF and CRLF line endings, including mixed endings and CRLF split across network chunks. They read a final completion or failure event even when the server closes the body without a trailing newline. An unfinished response or one stopped by a size limit still reports an error. When a search answer ends with a Sources list, an unmatched list item stays in the answer instead of being removed as a citation. A title followed by its URL is still consumed as one citation. Non-HTTP URI-like text inside inline code, such as `mode:fast`, stays in the answer; code-formatted HTTP(S) URLs are still citations. diff --git a/docs-site/src/content/docs/guides/sub-agent-surface.md b/docs-site/src/content/docs/guides/sub-agent-surface.md index a7bd6c543eb..eab0c0000b8 100644 --- a/docs-site/src/content/docs/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/guides/sub-agent-surface.md @@ -151,7 +151,8 @@ description of the work Codex usually hands off, sizes it with one call to your and proposes the cheapest sufficient model and an effort from the list the picker offers, with the reasoning and the signals that would move it up or down. Nothing changes until you choose **Use this**, which saves exactly like picking the model and effort by hand. Tiers come from -`codexRoleTiers` or price, as in [role auto-assign](/guides/integrations/#auto-assign). +`codexRoleTiers` or price, as in [role auto-assign](/guides/integrations/#auto-assign), without the +measured scores role auto-assign can read from configured sources. `multiAgentGuidanceEnabled` defaults to on and is the master switch for opencodex-authored guidance on both surfaces. Turning it off suppresses both the v2 designation block and v1 proactive text. diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 677bd9e46d4..7426627eeab 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -131,7 +131,7 @@ is visible) and never forces an upstream refresh. | **Providers** | Add, edit, set the default (enabled providers only), enable/disable, and remove providers; manage OAuth account pools and API-key pools where supported. Removing the current default switches to the first remaining enabled provider when one exists; otherwise deletion is refused and the current default is kept. Provider Settings can disable live model discovery for endpoints with missing, slow, or oversized `/models` catalogs. For Claude (Anthropic) OAuth pools, each logged-in account shows its own 5-hour and weekly rate-limit bars (usage is per credential); a failed probe keeps the last-known bars and marks them unavailable until the next successful refresh. Refreshing quota skips paused OAuth accounts and disabled API keys; skipped rows without a cached reading remain unavailable but do not affect the refresh result. Refreshing reports failure when an unpaused OAuth account or enabled API key that supports quota reads has an unavailable reading. The Provider Overview shown when no provider is selected carries a **Refresh all quotas** control that forces one server-side re-read of every configured provider; a provider whose upstream probe fails keeps its last-good row, so the status line reports that the check completed rather than claiming every value is fresh, and each row's own age stays the per-provider freshness signal. | | **Add provider** | One search above the tabs reaches all four at once — Accounts, Free, Local, Paid. While a query is live the results are grouped by tab with a count each, and the selected tab stays put rather than jumping. Press ArrowDown in the search box to focus the first available result action, skipping disabled buttons; if no action is available, focus stays in the search box. Local runtimes (Ollama, vLLM, LM Studio, LiteLLM) have their own tab, and a long provider note clamps to two lines with the full text one click away. Claude Code CLI (subscription) sits under Paid with a Subscription CLI badge, and selecting it warns that it bills the signed-in Claude subscription through `claude -p` and never uses an API key (use `anthropic-apikey` for key billing). | | **Codex Auth** | Add ChatGPT/Codex pool accounts, select the next-session account, refresh 5h / weekly / 30d quotas, enable or disable quota auto-switch, set its 1–100% threshold, and configure transient-failure failover. | -| **Subagents** | Feature up to five bare native or namespaced routed models in the `spawn_agent` override list. | +| **Subagents** | Feature up to five bare native or namespaced routed models in the `spawn_agent` override list. The **Workflow limits for subagents** panel sets how many turns one task may run in parallel and how many model requests and distinct subagents it may use per 10-minute window. See [workflow limits](/reference/configuration/server/#workflow-limits). | | **Models** | Toggle native GPT and routed models, set provider allowlists and context caps, choose v1/base/v2, and configure the v2 thread limit. The page distinguishes a catalog saved on the hub, a catalog fetched by this client, and activation in a running client. A fetch timestamp does not prove it includes the latest hub save, and runtime activation is shown as unverified. Configured providers stay visible as zero-model groups when discovery is off or returns no rows. | | **Logs** | Auto-refresh recent requests with tokens, requested effort and (when available) effective outbound effort, resolved model, provider, status, request id, duration, and error details. The detail view includes the exact reasoning wire field when the adapter emits one. Filter by opaque conversation/session id (when the client sends one) to total tokens and estimated list-price cost for the currently loaded Logs ring. | | **Usage / Debug** | Inspect token-usage coverage and trends. The Usage page's Models table also breaks each model down into input tokens, output tokens, cache hits, cache writes, and cache hit rate; a dash means cache telemetry for that metric is unavailable. Or enable opt-in provider transport and usage-extraction diagnostics. | diff --git a/docs-site/src/content/docs/ja/guides/combos.md b/docs-site/src/content/docs/ja/guides/combos.md index 943ccef2bc0..9823756979f 100644 --- a/docs-site/src/content/docs/ja/guides/combos.md +++ b/docs-site/src/content/docs/ja/guides/combos.md @@ -64,6 +64,8 @@ ocx combo show main ## 戦略を選択してください +必須の `tool_choice` を維持できないターゲットは送信前にスキップされ、すべて非対応の場合はその制約を示すエラーを返します。自動選択のみを許可する Chat モデルと、OpenAI 仮想モデルの実際の送信モデルのポリシーも判定対象です。直接の hosted/MCP 選択、`tool_search`、無効な選択値の扱いは[英語の基準説明](/guides/combos/#failover-ordered-primary-and-backups)を参照してください。 + ### フェイルオーバー: 順序付けされたプライマリとバックアップ `failover` は、構成順序で最初の適格なターゲットを選択します。ターゲットは、プロバイダーが存在し、有効になっていて、クールダウン中でなく、特別なリクエスト制約を処理できる場合に適格となります。重みと `stickyLimit` はこの戦略に影響しません。 diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index 616cb1e164e..7ecffe3764a 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -118,7 +118,7 @@ account を削除しても mapping は保持され、同じ id を再追加す | `models?` | `string[]` | 初期/フォールバックモデル一覧。`liveModels: false` で `models` が空でなければ、その後に `retainModels` を追加します。`models` が空または省略されている場合は、設定済みの `defaultModel`、`retainModels` の順に初期一覧を作り、重複 ID は最初の出現だけを残します。 | | `liveModels?` | `boolean` |開始/同期時にライブ カタログをフェッチします (デフォルトは `true`)。カスタムプロバイダーは `${baseUrl}/models` を使用します。組み込みはレジストリ URL とフィルターを使用する場合があります。 | | `selectedModels?` | `string[]` |検出後のカタログ許可リスト。空でない場合は、それらの ID のみが公開されます。空または省略すると、検出されたすべてのモデルが公開されます。 | -| `modelDisplayNames?` | `Record` | このプロバイダーの正確なネイティブモデル ID をキーにした、永続的な表示専用ラベルです。大文字と小文字は区別されます。ラベルはプロバイダーカタログのメタデータより優先され、認証、アダプター、ルーティング、課金、上流リクエストには影響しません。マップは検出上限と同じ 2,000 件までです。 | +| `modelDisplayNames?` | `Record` | このプロバイダーの正確なネイティブモデル ID をキーにした、永続的な表示専用ラベルです。大文字と小文字は区別されます。ラベルはプロバイダーカタログのメタデータより優先され、認証、アダプター、ルーティング、課金、上流リクエストには影響しません。例外として、直接の `provider/model` セレクターが既知の ID、エイリアス、`retainModels` のいずれにも一致しない場合(ライブ一覧がまだ取得されていない間など)、エンコード形が一致するキーをネイティブ ID として送信します。マップは検出上限と同じ 2,000 件までです。 | | `contextWindow?` | `number` | アップストリームのメタデータが無い場合に使うプロバイダー全体のコンテキスト値。メタデータがある場合は上限として働き、より小さいライブ値をそのまま残します。Models ダッシュボードでは `providerContextCaps` とは別に設定します。 | | `modelContextWindows?` | `Record` | モデルごとのコンテキスト値および上限。`contextWindow` より優先され、ウィンドウが不明なら設定値を使い、より小さいライブメタデータがあればそちらが優先されます。 | | `inferContextWindow?` | `boolean` | カスタムプロバイダーのみ、既定は無効。`true` で欠けたウィンドウを同梱モデルメタデータから補完します。推定値であり、中継の容量を検証したものではありません。 | @@ -189,9 +189,9 @@ API キープロバイダーは、リテラルキーまたは環境参照を保 ### プロバイダーの保存で保持されるもの -既存のプロバイダー名で `POST /api/providers` を送ると、保存済みの行はリクエストから組み立てた行で置き換えられます。ダッシュボードの追加・編集フォームはすべてのフィールドを送れないため、リクエストが省略した保存済みフィールドの一部は保存時に引き継がれます。そのうち次の 10 つは、特定のアップストリームの挙動を記録するものです:`preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`。 +既存のプロバイダー名で `POST /api/providers` を送ると、保存済みの行はリクエストから組み立てた行で置き換えられます。ダッシュボードの追加・編集フォームはすべてのフィールドを送れないため、リクエストが省略した保存済みフィールドの一部は保存時に引き継がれます。そのうち次の 11 つは、特定のアップストリームの挙動を記録するものです:`preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `promoteAdditionalTools`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`。 -| 保存 | 10 つの設定 | 保存済みの `apiKeyPool` | +| 保存 | 11 つの設定 | 保存済みの `apiKeyPool` | | --- | --- | --- | | 同じ宛先、フィールド省略 | 保存済みの値を保持(明示的な `[]` や `false` を含む) | 保持 | | 新しい宛先、フィールド省略 | 保持しない。新しい宛先のレジストリ既定値が適用されることがあります | 保持しない | diff --git a/docs-site/src/content/docs/ko/guides/combos.md b/docs-site/src/content/docs/ko/guides/combos.md index 40ad3327e05..b81448df36b 100644 --- a/docs-site/src/content/docs/ko/guides/combos.md +++ b/docs-site/src/content/docs/ko/guides/combos.md @@ -70,6 +70,8 @@ alias는 클라이언트가 요청하는 공개 이름만 바꿉니다. 콤보 ## 전략 선택 +필수 `tool_choice`를 보존할 수 없는 대상은 전송 전에 건너뛰며, 모든 대상이 호환되지 않으면 해당 제약을 오류로 반환합니다. 자동 선택만 허용하는 Chat 모델과 OpenAI 가상 모델의 실제 전송 모델 정책도 이 판단에 반영됩니다. 직접 hosted/MCP 선택, `tool_search`, 잘못된 선택값의 처리 범위는 [영문 기준 설명](/guides/combos/#failover-ordered-primary-and-backups)을 참고하세요. + ### 페일오버: 순서가 있는 기본값과 예비값 `failover`는 설정 순서에서 가장 먼저 적합한 대상을 선택합니다. 대상이 적합하려면 해당 공급자가 존재하고, 활성화되어 있고, 쿨다운 중이 아니며, 요청에 붙은 특수 조건을 처리할 수 있어야 합니다. 이 전략에서는 가중치와 `stickyLimit`이 영향을 주지 않습니다. diff --git a/docs-site/src/content/docs/ko/guides/sidecars.md b/docs-site/src/content/docs/ko/guides/sidecars.md index 93701719528..1ad25a749b3 100644 --- a/docs-site/src/content/docs/ko/guides/sidecars.md +++ b/docs-site/src/content/docs/ko/guides/sidecars.md @@ -161,7 +161,7 @@ fingerprint 선례를 따르지만, 실제 계정과 작업량으로 충분히 s 검색 응답의 선택 필드 형식이 잘못되어도 유효한 답변과 출처는 유지합니다. 오류 메시지 값의 형식이 잘못되면 일반 실패 메시지를 표시합니다. -OpenAI 검색·비전 사이드카는 서버가 마지막 줄바꿈 없이 응답 본문을 닫아도 마지막 완료 또는 실패 이벤트를 읽습니다. 완료되지 않았거나 크기 제한으로 중단된 응답은 계속 오류로 처리합니다. +OpenAI 검색·비전 사이드카는 CR, LF, CRLF 줄바꿈과 이들이 섞인 응답을 처리하며, CRLF가 여러 네트워크 청크로 나뉘어도 이벤트를 읽습니다. 서버가 마지막 줄바꿈 없이 응답 본문을 닫아도 마지막 완료 또는 실패 이벤트를 읽습니다. 완료되지 않았거나 크기 제한으로 중단된 응답은 계속 오류로 처리합니다. 검색 답변의 Sources 목록에서 URL과 짝을 이루지 못한 글머리표는 출처로 삭제하지 않고 답변에 남깁니다. 제목 다음 줄에 해당 URL이 있으면 하나의 출처로 처리합니다. `mode:fast`처럼 인라인 코드 안의 비 HTTP URI 형태 텍스트는 답변에 남기며, 코드로 표시한 HTTP(S) URL은 출처로 처리합니다. diff --git a/docs-site/src/content/docs/ko/reference/cli/agents.md b/docs-site/src/content/docs/ko/reference/cli/agents.md index e6186944fb4..102cff039a0 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -193,6 +193,8 @@ family는 `opus`, `fable`, `sonnet`, `haiku`이며, 새 route는 `opus`에서 일치가 확인된(`matched`) autostart wrapper만 backing Codex를 직접 실행합니다. 검사 예외를 포함한 알 수 없는 상태는 검증된 bypass가 필요하며, 관리되는 Windows CMD wrapper는 현재 생성 템플릿 전체와 일치해야 `OCX_SHIM_BYPASS=1`로 실행합니다. 일반 npm 런처는 지원합니다. 검증할 수 없거나 오래된 wrapper는 종료 코드 1로 거부하며 Codex를 시작하지 않습니다. `ocx codex-shim install`로 갱신하세요. 이 실행은 shim을 수리하지 않으며, 내용 검사는 Windows backing 파일 출처나 동시 경로 교체를 보장하지 않습니다. +macOS에서는 도움말과 버전을 포함한 매 실행 시, 선택한 실행 파일의 경로나 정규 경로가 `.app` 번들 내부에 있으면 검증합니다. 이름을 바꾼 번들과 번들 내부를 가리키는 심볼릭 링크도 번들 및 실행 파일의 안전한 소유권, 권한, OpenAI 서명을 요구합니다. 선택한 번들 구성원은 실행 파일, 중간 디렉터리 또는 번들 전체의 심볼릭 링크를 거쳐도 해당 번들의 정규 `Contents/` 내부에 있어야 합니다. 검증 실패 시 Codex를 시작하지 않고 종료 코드 1을 반환합니다. npm 설치를 포함한 일반 번들 외부 실행 파일의 동작은 유지됩니다. + 런처 도움말은 `ocx help codex`, Codex 도움말은 `ocx codex --help`입니다. 이후 plain `codex`의 동작은 기존과 같습니다. ### `ocx opencode [opencode args...]` diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index b454ff2022d..6102a5442e6 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -118,7 +118,7 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | `models?` | `string[]` | 초기/폴백 모델 목록입니다. `liveModels: false`에서 `models`가 비어 있지 않으면 `models`, `retainModels` 순으로 구성합니다. `models`가 비어 있거나 생략되면 설정된 `defaultModel`, `retainModels` 순으로 구성하고, 중복 ID는 처음 나온 항목만 남깁니다. | | `liveModels?` | `boolean` | 시작 또는 동기화 시 라이브 카탈로그를 가져옵니다. 기본값은 `true`입니다. 사용자 지정 공급자는 `${baseUrl}/models`를 사용하고, 내장은 레지스트리 URL을 사용한 뒤 필터링할 수 있습니다. | | `selectedModels?` | `string[]` | 발견 후 카탈로그 허용 목록입니다. 값이 비어 있지 않으면 그 id만 노출하고, 비어 있거나 생략하면 발견된 모델을 모두 노출합니다. | -| `modelDisplayNames?` | `Record` | 이 공급자의 정확한 네이티브 모델 id를 키로 쓰는 영구 표시 전용 이름입니다. 키는 대소문자를 구분합니다. 이름은 공급자 카탈로그 메타데이터보다 우선하며 인증, 어댑터, 라우팅, 청구 또는 업스트림 요청을 바꾸지 않습니다. 맵은 발견 한도와 같은 최대 2,000개 항목을 가질 수 있습니다. | +| `modelDisplayNames?` | `Record` | 이 공급자의 정확한 네이티브 모델 id를 키로 쓰는 영구 표시 전용 이름입니다. 키는 대소문자를 구분합니다. 이름은 공급자 카탈로그 메타데이터보다 우선하며 인증, 어댑터, 라우팅, 청구 또는 업스트림 요청을 바꾸지 않습니다. 단, 직접 `provider/model` 선택자가 알려진 id, 별칭, `retainModels` 어디에도 맞지 않으면(예: 라이브 목록을 아직 받지 못한 동안) 인코딩 형태가 같은 키를 네이티브 id로 보냅니다. 맵은 발견 한도와 같은 최대 2,000개 항목을 가질 수 있습니다. | | `contextWindow?` | `number` | 업스트림 메타데이터가 없을 때 쓰이는 공급자 전반의 컨텍스트 값입니다. 메타데이터가 있으면 상한으로 동작해 더 작은 라이브 값을 그대로 둡니다. Models 대시보드에서 `providerContextCaps`와 별도로 설정합니다. | | `modelContextWindows?` | `Record` | 모델별 컨텍스트 값이자 상한입니다. `contextWindow`보다 우선하며, 창 크기를 알 수 없으면 설정값을 쓰고 더 작은 라이브 메타데이터가 있으면 그쪽을 따릅니다. | | `inferContextWindow?` | `boolean` | 사용자 지정 공급자 전용이며 기본값은 꺼짐입니다. `true`이면 빠진 창을 번들 모델 메타데이터로 채웁니다. 추정값이며 중계의 실제 용량을 검증하지 않습니다. | @@ -189,9 +189,9 @@ API 키 공급자는 리터럴 키나 환경 참조를 둘 수 있습니다. OAu ### 프로바이더 저장이 유지하는 것 -기존 프로바이더 이름으로 `POST /api/providers`를 보내면 저장된 행이 요청으로 만든 행으로 바뀝니다. 대시보드의 추가/편집 폼은 모든 필드를 보낼 수 없으므로, 요청이 빠뜨린 저장 필드 일부는 저장할 때 이어서 유지됩니다. 그중 열 가지는 특정 업스트림의 동작을 기록한 설정입니다: `preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`. +기존 프로바이더 이름으로 `POST /api/providers`를 보내면 저장된 행이 요청으로 만든 행으로 바뀝니다. 대시보드의 추가/편집 폼은 모든 필드를 보낼 수 없으므로, 요청이 빠뜨린 저장 필드 일부는 저장할 때 이어서 유지됩니다. 그중 열한 가지는 특정 업스트림의 동작을 기록한 설정입니다: `preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `promoteAdditionalTools`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`. -| 저장 | 열 가지 설정 | 저장된 `apiKeyPool` | +| 저장 | 열한 가지 설정 | 저장된 `apiKeyPool` | | --- | --- | --- | | 같은 목적지, 필드 생략 | 저장된 값 유지(명시적인 `[]`나 `false` 포함) | 유지 | | 새 목적지, 필드 생략 | 유지하지 않음. 새 목적지의 레지스트리 기본값이 적용될 수 있음 | 유지하지 않음 | diff --git a/docs-site/src/content/docs/ko/reference/configuration/routing.md b/docs-site/src/content/docs/ko/reference/configuration/routing.md index d19dce2f092..fed5f09dbfc 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/routing.md +++ b/docs-site/src/content/docs/ko/reference/configuration/routing.md @@ -121,6 +121,27 @@ CLI: `ocx route policy list`, `ocx route policy show `, `ocx route policy dr CLI: `ocx logs explain `, `ocx logs rebuild-index`, `ocx logs index-status`. +### 인덱스 검사와 복구 + +기존 기록 인덱스를 처음 열면 읽기 전용 워커에서 무결성을 검사합니다. +기록 조회는 이 초기 검사를 기다리며, 프록시의 요청 처리 스레드를 막지 않습니다. +동기식 라우팅 조회는 검사 중에 busy를 반환하므로 라우팅 프로필의 +`unknownEvidence.health` 정책이 적용됩니다. 작은 인덱스를 인라인으로 재구축한 경우에는 +성공 직후 사용할 수 있으며, 워커에서 재구축한 인덱스는 게시 전에 검사를 마칩니다. + +검사나 재구축에 성공한 뒤 최소 5분이 지나면 다음 조회가 백그라운드 무결성 검사 하나를 +시작합니다. 이미 검증된 인덱스는 검사 중에도 조회할 수 있습니다. 일반 갱신은 스키마, +원본 파일 식별자와 바이트 오프셋을 확인하지만 전체 동기식 검사를 반복하지 않으며, +조회가 없는 동안에는 타이머를 실행하지 않습니다. 데이터베이스 잠금이나 워커 실행 실패 +같은 일시적인 검사 오류는 인덱스를 삭제하지 않고, 30초가 지난 뒤의 조회에서 다시 시도합니다. +초기 검증을 마치지 못한 인덱스는 그동안 사용할 수 없습니다. + +손상이 탐지되면 `usage.jsonl`에서 자동으로 재구축하며, 영향을 받은 비동기 기록·분석 +조회는 복구 후 한 번 재시도합니다. 인덱스를 닫거나 재구축할 때에는 이전 검사 결과를 +폐기하고 검사 워커가 파일을 해제한 뒤 인덱스를 교체합니다. 검사와 복구는 원본 기록을 +다시 쓰지 않습니다. 초기 검사에는 인덱스 크기에 비례하는 시간이 걸리며, +이후 백그라운드 검사는 인덱스를 사용하는 동안에만 실행됩니다. + ## 마이그레이션 `routingProfiles`는 선택적 추가 설정입니다. 기존 설정 파일과 이전 `usage.jsonl` 행은 그대로 읽힙니다. 인덱스는 일회용이며 삭제 시 다음 쿼리에서 `usage.jsonl`로 자동 재구축됩니다. 자동 튜닝은 없습니다. diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 82cf7d00436..8d6ee982c97 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -260,6 +260,27 @@ blank strings and mixed encrypted/unknown parts are not partially converted. See [agent messages](/reference/configuration/providers/#routed-agent-messages) for the separate opt-in encrypted-task recovery behavior. +Meta Responses also receives Codex's current `additional_tools` declarations in the public +`tools` array. The catalog is captured before history normalization: replayed declarations +and historical tool-search discoveries cannot activate tools for the new turn. Current +namespace, custom-tool and long-name aliases retain the existing normalization and response +restoration. Current declarations keep their parameter schemas when search results repeat a tool. +Historical custom calls retain their existing function-call representation without granting +those old tools current authority. Nested function definitions become public declarations. +Malformed or unrepresentable namespace catalogs return HTTP 400 before inference, including +requests with string or omitted input, rather than being partially promoted. + +For Meta Responses at the exact `api.meta.ai` host, OpenCodex omits the unsupported +`output.encrypted_content` include requested by Codex Desktop. Meta otherwise rejects the +request before inference. `reasoning.encrypted_content` and other include entries remain +intact, so stateless reasoning replay is preserved. The same exact-entry normalization +applies to xAI; unrelated Responses destinations keep their original include list. + +These request-shape fixes do not change account entitlements. Meta limits Muse Code +subscription credentials to the Muse Code client; other clients require a separately +billed Meta API key. A subscription key can therefore return HTTP 403 through OpenCodex +even when the request shape is valid. See [Meta's subscription rules](https://dev.meta.ai/docs/muse-code/subscriptions). + For xAI Responses, `auto` or `none` tool selection is omitted when normalization leaves no tools in the request, including when cached-only search is removed. Valid forced function selections remain intact. Replayed custom tool calls with missing or invalid item ids receive stable ids @@ -802,6 +823,11 @@ appended to the id instead. **Targets:** **Azure OpenAI**. Wraps `openai-responses` (so also `passthrough: true`). **Auth:** `key` via the `api-key` header (not Bearer). +Azure requests omit the unsupported output.encrypted_content entry from include, +while retaining reasoning.encrypted_content and all other entries. This also applies +to openai-responses configurations targeting a resource under .openai.azure.com. +When no entries remain, include is omitted. + - Delegates request building to the Responses passthrough, validates that `baseUrl` contains no unresolved template placeholder, and replaces `Authorization` with `api-key`. The configured URL targets Azure's v1 Responses API directly, so the adapter does not append `api-version`. diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index fa0798d1e3d..bce042afced 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -994,6 +994,8 @@ A missing `OPENCODEX_API_AUTH_TOKEN` is read from this installation's `service-a Only a matched autostart wrapper launches its backing Codex directly. Unknown state, including an inspection exception, requires a verified bypass; Windows CMD managed wrappers must match the complete current generated template before the selected absolute command runs with `OCX_SHIM_BYPASS=1`. Ordinary npm launchers remain supported. Unverifiable or obsolete wrappers refuse with exit 1 and start no Codex; run `ocx codex-shim install` to refresh them. This launch does not repair shims. The content check does not establish Windows backing-file provenance or prevent concurrent pathname replacement. +On macOS, every launch, including help/version, verifies the selected executable when its selected path or canonical location is inside any `.app` bundle. Renamed bundles and symlinks into bundles require the same safe ownership, permissions and OpenAI signatures for the bundle and executable. A selected bundle member must stay inside that bundle's canonical `Contents/`, including through executable, directory or whole-bundle symlinks. Failed verification exits 1 without starting Codex. Ordinary non-bundle executables, including npm installations, retain their behavior. + Use `ocx help codex` for launcher help and `ocx codex --help` for Codex help. Launching plain `codex` later retains its existing behavior. ### `ocx opencode [opencode args...]` diff --git a/docs-site/src/content/docs/reference/configuration/agents.md b/docs-site/src/content/docs/reference/configuration/agents.md index 1b6791280cd..d2474b94f1c 100644 --- a/docs-site/src/content/docs/reference/configuration/agents.md +++ b/docs-site/src/content/docs/reference/configuration/agents.md @@ -56,7 +56,8 @@ still depends on upstream support for your account. | `subagentModelFallback?` | `string[]` | `[]` | Priority-ordered global fallback models for spawned child turns. | | `subagentModelFallbackByModel?` | `Record` | `{}` | Per-primary-model fallback chains, keyed by the requested primary model id. This is the supported home for per-role fallback metadata; `model_fallback` inside Codex agent TOML makes Codex 0.146+ skip the role (#1190). | | `subagentModelFallbackPollMs?` | `number` | `60000` | Availability-probe cache interval. Values below 1000 ms fall back to the default. | -| `codexRoleTiers?` | `{ fast?, standard?, frontier?: string[] }` | — | Capability tier of named models for [role auto-assign](/guides/integrations/#auto-assign). Listed models take that tier; other models are tiered by price, and unpriced unlisted models are never proposed. | +| `codexRoleTiers?` | `{ fast?, standard?, frontier?: string[] }` | — | Capability tier of named models for [role auto-assign](/guides/integrations/#auto-assign). Listed models take that tier; other models are tiered by measured score in the role's domain when two or more have one, without requiring prices. Equal scores share a tier. Otherwise price tiering excludes unpriced, unlisted models. | +| `codexRoleCapability?` | `{ artificialAnalysis?: { apiKey?: string } }` | No measured source | Measured-score source for [role auto-assign](/guides/integrations/#auto-assign). Artificial Analysis uses your API key (literal, `${ENV}` or keychain reference) once per day; scores stay out of the response. Missing or unavailable data falls back to configured tiers and prices. A retired `huggingFace` key is ignored. | | `effortCap?` | `string` | — | Hard ceiling for qualifying v2 main turns and marked spawned-child turns. Accepts `low` through `ultra`. | | `subagentEffortCap?` | `string` | — | Additional ceiling for spawned-child turns only. When both caps apply, the lower wins. | | `plaintextV2AgentMessages?` | `boolean` | — (unset) | Experimental opt-in. It runs only when explicitly set to `true` and asks eligible native ChatGPT v2 parents to emit `spawn_agent`, `send_message`, and `followup_task` message arguments as plaintext. See [Plaintext v2 agent messages](#plaintext-v2-agent-messages). | diff --git a/docs-site/src/content/docs/reference/configuration/keychain.md b/docs-site/src/content/docs/reference/configuration/keychain.md new file mode 100644 index 00000000000..c8a4e40d384 --- /dev/null +++ b/docs-site/src/content/docs/reference/configuration/keychain.md @@ -0,0 +1,55 @@ +--- +title: Storing keys in the OS keychain +description: Storing provider API keys and Vertex credentials in macOS Keychain, Windows Credential Manager, or Linux Secret Service. +--- + +For Vertex, the dedicated API-key pool input (`POST /api/providers/keys`) accepts complete GCP +`service_account` or `authorized_user` credential JSON after the provider has been created. +The provider create/edit form and `ocx provider add --api-key` refuse credential JSON before +saving: create the provider without a key, then add the JSON through its key pool. Paste the +JSON contents, not a Windows, Unix, or relative file path. Multiple +JSON objects separated by commas or whitespace are stored together as separate pool entries; +the first becomes active. Configuration contains only `gcp-sa:` references, never the +credential JSON. Mixed JSON/literal-key bulk pastes are rejected as a whole; add literal keys +separately. Storage failures leave the configuration unchanged and report a fixed error without +echoing credential material. Empty marker accounts are invalid and do not fall back to host credentials. +Paste one credential object or a sequence of objects, without an enclosing array or quoted string. +Malformed credential text is rejected. If a selected stored credential is removed or becomes unreadable, +Vertex refuses the request even when it previously cached an access token for that credential. +Removing a key or provider, or replacing a provider with `ocx provider add --force`, deletes its unreferenced GCP credentials after the configuration is +saved; a credential referenced by another provider is retained. If OS-store cleanup fails, a +warning requests manual inspection without undoing the committed configuration removal. +Ordinary keychain store/restore actions leave GCP markers and JSON in the OS store. Restoring +credential JSON through a `keychain:` alias is refused, and cleanup preserves accounts still +referenced through either marker spelling. +Using a `keychain:` alias containing GCP JSON as an API key also refuses the request; select its +`gcp-sa:` reference for Vertex authentication instead. +Literal API keys must be plain token text: JSON objects, arrays and quoted JSON strings are refused +in literal-key resolution and restore, including wrappers containing escaped credential text. +Credential writes and marker persistence share the cleanup transaction lock, so concurrent +removal cannot delete the stored JSON between read-back and the marker commit. + +By default a provider's `apiKey` and `apiKeyPool` sit in `config.json` (mode 0600, atomic writes). +If you would rather keep the key material out of the file, move it into the OS credential store: + +```bash +ocx provider keychain deepseek status # store: file | env | keychain, and whether the keychain answers +ocx provider keychain deepseek store # move active key + pool into the OS keychain +ocx provider keychain deepseek restore # bring the plaintext back and delete the keychain items +``` + +The same operations are `GET`/`POST /api/providers/keychain`. After `store`, `config.json` holds +`"apiKey": "keychain:deepseek"` (pool entries `keychain:deepseek/`) and the secret lives under the +`opencodex.provider-api-key.v1` service in macOS Keychain, Windows Credential Manager, or the Linux +Secret Service. Backups of `config.json` therefore carry references only. Key rotation and failover +keep working: pool entries compare by reference, so a rotation never writes plaintext back. + +Before touching the config, `store` writes and reads back every entry; if the keychain is unavailable +or the read-back does not match, it refuses with 503 and leaves the file as it was. At request time +a reference that cannot be read yields no credential and one warning per key — there is no plaintext +fallback, by design. + +When not to opt in: a proxy running as a headless service (systemd, launchd, Task Scheduler) or in a +container usually has no unlocked keychain session, so requests would fail closed. Use an +`${ENV_VAR}` reference in the service environment there instead. Env references are left untouched +by `store`. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index f77855f4cc4..7ed86852041 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -326,7 +326,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `preserveResponsesMessageMetadata?` | `boolean` | Default off. Preserve private `internal_chat_message_metadata_passthrough` on input items for explicitly trusted launcher relays, including custom native v1 compact requests. This forwards private turn/session metadata to that destination; do not enable for ordinary gateways. | | `dropResponsesReasoningItems?` | `boolean` | Remove replayed Responses `reasoning` items from continuation input entirely, including custom native v1 compact requests, instead of forwarding them with blanked content. This is lossy: summaries, item ids, and `encrypted_content` go with them, so no reasoning item from the previous turn remains in the forwarded input. It does not remove `previous_response_id`, so an upstream holding its own turn state may still reach it. Enable only for an upstream that rejects reasoning replay outright — the built-in Volcengine Ark Coding Plan preset sets it because validated tool continuations answered `400 InvalidParameter` otherwise. Set it to `false` on that provider to forward reasoning items again. | | `disabled?` | `boolean` | Keep the provider on disk but exclude it from routing and model/catalog listings. | -| `apiKey?` | `string` | API key, an `${ENV_VAR}` / `$ENV_VAR` reference, or a `keychain:` reference written by `ocx provider keychain store`. References resolve at request time. See [Storing keys in the OS keychain](#storing-keys-in-the-os-keychain). | +| `apiKey?` | `string` | API key, an `${ENV_VAR}` / `$ENV_VAR` reference, or a `keychain:` reference written by `ocx provider keychain store`. References resolve at request time. See [Storing keys in the OS keychain](/reference/configuration/keychain/). | | `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Anthropic key header style. Defaults to native `x-api-key`; valid only for key-auth `anthropic` providers. | | `apiKeyPool?` | `ApiKeyPoolEntry[]` | Multi-key pool. `apiKey` mirrors the active entry; each item has `id`, `key`, optional `label`, and optional numeric `addedAt`. | | `apiKeyPoolStrategy?` | `"round-robin" \| "fill-first" \| "quota"` | How a warm key is chosen **before** the first attempt when the committed key is already cooling. Omitted keeps rotation reactive-only: the pool moves after a 429 or 401 and not before. `round-robin` takes the next key in the pool, `fill-first` keeps the first eligible one, and `quota` prefers the key with the most remaining headroom, falling back to `fill-first` order for a provider whose per-key quota is unknown. A healthy committed key is never overridden, so a manual key selection stands. | @@ -335,7 +335,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `liveModels?` | `boolean` | Fetch the live catalog on start/sync (default `true`). Custom providers use `${baseUrl}/models`; built-ins may use a registry URL and filter. | | `selectedModels?` | `string[]` | Catalog allowlist after discovery. Non-empty exposes only those ids; empty or omitted exposes all discovered models. | | `retainModels?` | `string[]` | Ids kept in the catalog even when live discovery omits them. They need not be repeated in `models`. Empty or omitted keeps today's behavior. | -| `modelDisplayNames?` | `Record` | Durable labels used only for display, keyed by this provider's exact upstream model id. Labels win over provider catalog metadata, survive discovery refreshes and provider edits, and never change authentication, adapter behavior, routing, billing, upstream request construction, the routed `provider/model` selector, or the upstream wire model. Keys are exact and case sensitive. Unknown model ids are kept so a temporarily missing model receives its label when it returns. The map accepts at most 2,000 entries, matching the discovery limit. | +| `modelDisplayNames?` | `Record` | Durable labels used only for display, keyed by this provider's exact upstream model id. Labels win over provider catalog metadata, survive discovery refreshes and provider edits, and never change authentication, adapter behavior, billing, upstream request construction, the routed `provider/model` selector, or the wire model of an id the provider already knows. One routing exception: when a direct `provider/model` selector matches no known id, alias or `retainModels` id (for example while a live roster is still cold), a key whose encoded form matches is sent as the native id. Keys are exact and case sensitive. Unknown model ids are kept so a temporarily missing model receives its label when it returns. The map accepts at most 2,000 entries, matching the discovery limit. | | `contextWindow?` | `number` | Provider-wide context fallback when upstream metadata is absent; otherwise a cap that retains smaller live metadata. Registry defaults cannot override a lower configured limit; explicit `modelContextWindows` entries take priority. The Models dashboard exposes this separately from `providerContextCaps`. | | `modelContextWindows?` | `Record` | Per-model context fallbacks/caps. These override `contextWindow`: an unknown window uses the configured value, while smaller live metadata remains authoritative. | | `inferContextWindow?` | `boolean` | Custom providers only, default off. `true` fills a missing window from bundled model-ID metadata ([details](#context-windows-on-custom-providers)); the value is an estimate, not verified relay capacity. | @@ -370,6 +370,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `xaiResponsesXSearch?` | `boolean` | Disabled by default. On an xAI Responses destination, append the provider-hosted `x_search` declaration only when a live `web_search` tool survives final request normalization. Existing declarations are not duplicated, caller `tool_choice`/`allowed_tools` selectors are never widened, and this is separate from the web-search sidecar's `search.xSearch` options. | | `modelPreferHostedTools?` | `Record` | Exact-model opt-in for non-forward Responses gateways that reserve a hosted-tool namespace. Currently accepts only `["image_generation"]`; a matching model must use the `openai-responses` wire and support that hosted tool. It removes colliding client `image_gen` declarations and rewrites their selectors to preserve caller tool choice. For OpenAI API virtual `-pro` models, the selected public ID is matched first and the resolved base wire-model ID is a fallback. `modelAdapters` resolves the public ID first, then the base ID; the second resolution determines the final wire. Other models retain normal alias behavior. | | `annotateEmptyToolOutputs?` | `boolean` | Replace a present-but-empty tool result with a short marker before it reaches the model, so a blank result is not read as a missing one. Applies to blank strings and text-only part arrays; image, file, and encrypted parts are never touched. Defaults to `true` for DeepSeek from the built-in registry and is otherwise unset. Set `false` to opt a provider out — an explicit `false` is preserved across later edits that omit the field. `PATCH /api/providers?name=` accepts `true`, `false`, or `null` to clear the override and return to registry-default behavior. | +| `promoteAdditionalTools?` | `boolean` | Whether to promote Codex Desktop's `additional_tools` input items into top-level `tools` and strip the wrapper from `input`. Enabled automatically for OpenCode Go and xAI (`api.x.ai` and `cli-chat-proxy.grok.com`) unless `authMode` is `"forward"`, where promotion needs an explicit `true`; other OpenAI-strict third-party gateways that return HTTP 422 for unknown item types in `input` can opt in here. `PATCH /api/providers?name=` accepts a boolean or `null` to clear it. | | `unsupportedHostedTools?` | `string[]` | Hosted tool declarations this Responses destination rejects, so they are stripped from `tools`, from client-loaded `additional_tools`, and from `tool_choice` instead of being forwarded and rejected upstream. Use it for an OpenAI-compatible gateway with a narrower capability set — one that accepts plain Responses requests and `function` tools but returns HTTP 400 for hosted `web_search` — so a text-only prompt is not failed by a capability it never needed. Accepts only hosted tool type names (`web_search`, `web_search_preview`, `file_search`, `computer_use_preview`, `computer_use`, `code_interpreter`, `image_generation`, `image_gen`, `mcp`, `tool_search`, `local_shell`, `x_search`); an unrecognized name is rejected rather than silently ignored. Spelling variants of one capability are aliased, so `["web_search"]` also denies `web_search_preview`. A provider cannot both deny a hosted tool here and prefer it in `modelPreferHostedTools`. Xiaomi MiMo Responses destinations (`xiaomimimo.com` and its subdomains, including `api.xiaomimimo.com` and `token-plan-cn.xiaomimimo.com`) automatically strip `web_search` and `web_search_preview` while preserving function tools; no declaration is needed for these hosts. This is independent of `supportsResponsesCustomTools`; set both for a gateway that also rejects native custom tools. `PATCH /api/providers?name=` accepts an array or `null` to clear it. | | `reasoningEffortMap?` | `Record` | Provider-wide wire aliases for reasoning labels. Map a label to `"__omit__"` to drop the reasoning field from the upstream request entirely: `reasoning_effort` on an OpenAI-compatible wire, and Ollama's native `think` field on the Ollama native adapter (#2356). | | `modelReasoningEffortMap?` | `Record>` | Per-model wire aliases for reasoning labels. Map a label to `"__omit__"` to drop the reasoning field from the upstream request entirely. | @@ -487,9 +488,9 @@ and `modelContextWindows` for that exact id when needed. Unknown ids are not gue ### What a provider save keeps -`POST /api/providers` with the name of an existing provider replaces the stored row with one built from the request. The dashboard's add/edit form cannot send every field, so the save keeps some stored fields the request omits. Ten of them record how one upstream behaves: `preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `retryOn429`, `transientRetryOn5xx` and `retryOnReset`. +`POST /api/providers` with the name of an existing provider replaces the stored row with one built from the request. The dashboard's add/edit form cannot send every field, so the save keeps some stored fields the request omits. Eleven of them record how one upstream behaves: `preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `promoteAdditionalTools`, `retryOn429`, `transientRetryOn5xx` and `retryOnReset`. -| Save | The ten settings | Stored `apiKeyPool` | +| Save | The eleven settings | Stored `apiKeyPool` | | --- | --- | --- | | Same destination, field omitted | Stored value kept, including an explicit `[]` or `false` | Kept | | New destination, field omitted | Not kept; registry defaults for the new destination may apply | Not kept | @@ -1589,60 +1590,11 @@ same mapping applies to a native `vercel/` selector: use the encoded `vercel-ai-gateway/vercel-` selector in OpenCodex and keep `vercel/` as the model key. -## Static model allowlists - ## Storing keys in the OS keychain -For Vertex, the dedicated API-key pool input (`POST /api/providers/keys`) accepts complete GCP -`service_account` or `authorized_user` credential JSON after the provider has been created. -The provider create/edit form and `ocx provider add --api-key` refuse credential JSON before -saving: create the provider without a key, then add the JSON through its key pool. Paste the -JSON contents, not a Windows, Unix, or relative file path. Multiple -JSON objects separated by commas or whitespace are stored together as separate pool entries; -the first becomes active. Configuration contains only `gcp-sa:` references, never the -credential JSON. Mixed JSON/literal-key bulk pastes are rejected as a whole; add literal keys -separately. Storage failures leave the configuration unchanged and report a fixed error without -echoing credential material. Empty marker accounts are invalid and do not fall back to host credentials. -Paste one credential object or a sequence of objects, without an enclosing array or quoted string. -Malformed credential text is rejected. If a selected stored credential is removed or becomes unreadable, -Vertex refuses the request even when it previously cached an access token for that credential. -Removing a key or provider, or replacing a provider with `ocx provider add --force`, deletes its unreferenced GCP credentials after the configuration is -saved; a credential referenced by another provider is retained. If OS-store cleanup fails, a -warning requests manual inspection without undoing the committed configuration removal. -Ordinary keychain store/restore actions leave GCP markers and JSON in the OS store. Restoring -credential JSON through a `keychain:` alias is refused, and cleanup preserves accounts still -referenced through either marker spelling. -Using a `keychain:` alias containing GCP JSON as an API key also refuses the request; select its -`gcp-sa:` reference for Vertex authentication instead. -Literal API keys must be plain token text: JSON objects, arrays and quoted JSON strings are refused -in literal-key resolution and restore, including wrappers containing escaped credential text. -Credential writes and marker persistence share the cleanup transaction lock, so concurrent -removal cannot delete the stored JSON between read-back and the marker commit. - -By default a provider's `apiKey` and `apiKeyPool` sit in `config.json` (mode 0600, atomic writes). -If you would rather keep the key material out of the file, move it into the OS credential store: - -```bash -ocx provider keychain deepseek status # store: file | env | keychain, and whether the keychain answers -ocx provider keychain deepseek store # move active key + pool into the OS keychain -ocx provider keychain deepseek restore # bring the plaintext back and delete the keychain items -``` +API keys and credential references can be stored in the OS credential store (macOS Keychain, Windows Credential Manager, Linux Secret Service) instead of `config.json`. See [Storing keys in the OS keychain](/reference/configuration/keychain/) for details on migration commands, storage rules, and headless-service caveats. -The same operations are `GET`/`POST /api/providers/keychain`. After `store`, `config.json` holds -`"apiKey": "keychain:deepseek"` (pool entries `keychain:deepseek/`) and the secret lives under the -`opencodex.provider-api-key.v1` service in macOS Keychain, Windows Credential Manager, or the Linux -Secret Service. Backups of `config.json` therefore carry references only. Key rotation and failover -keep working: pool entries compare by reference, so a rotation never writes plaintext back. - -Before touching the config, `store` writes and reads back every entry; if the keychain is unavailable -or the read-back does not match, it refuses with 503 and leaves the file as it was. At request time -a reference that cannot be read yields no credential and one warning per key — there is no plaintext -fallback, by design. - -When not to opt in: a proxy running as a headless service (systemd, launchd, Task Scheduler) or in a -container usually has no unlocked keychain session, so requests would fail closed. Use an -`${ENV_VAR}` reference in the service environment there instead. Env references are left untouched -by `store`. +## Static model allowlists The `zhipu-bigmodel-responses` preset seeds `glm-5.3`, `glm-5.3-flash` and `glm-5-turbo` with `liveModels: false` for `https://open.bigmodel.cn/api/v1`. Its static roster and diff --git a/docs-site/src/content/docs/reference/configuration/routing.md b/docs-site/src/content/docs/reference/configuration/routing.md index e518b009789..63a3a0eae1f 100644 --- a/docs-site/src/content/docs/reference/configuration/routing.md +++ b/docs-site/src/content/docs/reference/configuration/routing.md @@ -401,6 +401,29 @@ CLI: `ocx logs history`, `ocx logs get `, `ocx logs explain `, `ocx logs rebuild-index`, `ocx logs index-status`, `ocx route policy list | show | dry-run | evaluate`. +### Index validation and recovery + +An existing history index is checked in a read-only worker when first opened. +History queries wait for that initial check without blocking the proxy's serving +thread. Synchronous routing reads report busy during validation, so routing +profiles apply their `unknownEvidence.health` policy. A successful small inline +rebuild is usable immediately; a worker rebuild is checked before publication. + +After a successful check or rebuild, the next read at least five minutes later +starts one background integrity check. Reads from the verified index continue +during that check. Normal refreshes retain schema, source-identity and offset +validation without a full synchronous scan, and an idle index starts no timer. +Temporary check failures, such as a busy database or unavailable worker, leave +the index intact and retry on a subsequent read after a 30-second backoff. +An index that has not completed initial validation remains unavailable meanwhile. + +Detected corruption triggers automatic reconstruction from `usage.jsonl`, with +one retry for an affected asynchronous history or analytics read. Closing or +rebuilding the index discards old check results and waits for the scanner to +release the file before replacing it. Validation and recovery never rewrite the +canonical ledger. The initial check still takes time proportional to index size; +later background checks run only when the index is used. + ## Migration `routingProfiles` is optional and additive: existing config files load diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index bff693deae6..98636bbedba 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -34,6 +34,7 @@ passthrough, including native WebSocket output, retain their existing stop behav | `codexNativeSteering?` | `boolean` | `false` | Experimental, native-only mid-turn steering on the Responses WebSocket endpoint. Requires `websockets: true`, a compatible upstream/client, and a pinned account/model/tool surface. Validated generation settings can change in explicit saved-result continuations. Does not enable translated models or HTTP fallback. See [native steering](/guides/codex-integration/#experimental-native-mid-turn-steering). | | `codexNativeInjection?` | `boolean` | `false` | Experimental saved function-result injection on compatible native multi-agent WebSocket turns. Requires `websockets: true`, explicit `multi_agent.enabled`, and an eligible provider. Separate from steering; no automatic tool rerun or recovery create. See [native injection](/guides/codex-integration/#experimental-native-function-result-injection). | | `nativeReasoningRetention?` | `{ modelSwitch?: boolean; accountSwitch?: boolean }` | both `false` | Permit reasoning ciphertext forwarding across a known model or account change on the same canonical ChatGPT backend. Account retention is experimental. See [Native reasoning retention](#native-reasoning-retention). | +| `workflowBudget?` | `{ maxConcurrentChildren?: number; maxPhysicalSends?: number; maxDistinctChildren?: number }` | `8` / `256` / `64` | Per-task fan-out ceilings: turns running at once, upstream sends per 10-minute window, and distinct subagent threads per window. Omitted fields keep the built-in value. See [Workflow limits](#workflow-limits). | | `corsAllowOrigins?` | `string[]` | `[]` | Additional exact origins allowed by CORS. Loopback origins are always allowed. Authority-based browser extension origins such as `chrome-extension://` are supported; `*` is not a wildcard. Firefox and Safari regenerate the extension UUID (per install / per browser launch), so update the entry when the origin changes. | | `apiKeys?` | `OcxApiKey[]` | `[]` | Generated `ocx_…` data-plane admission credentials on non-loopback binds. They do not authorize management APIs; management access uses the separate credential documented in the [management reference](/reference/management-api/). Dashboard-managed. | | `storageCleanupPolicy?` | `StorageCleanupPolicy` | disabled | Opt-in archived-session cleanup policy. Never enabled implicitly. | @@ -87,6 +88,29 @@ Retention means that OpenCodex may forward the carried ciphertext. It cannot con Use `GET /api/native-reasoning-retention` to read the resolved booleans and `PUT` with a partial boolean object to update them. `PUT` with JSON `null` restores both defaults. Invalid fields or unknown keys are rejected; a malformed file block disables retention with a warning. Dashboard/API changes apply to subsequent requests. Local `ocx config set/unset` edits require a restart to converge the running proxy. +## Workflow limits + +OpenCodex bounds how much one task may fan out. A task is the root conversation, identified by the client's `x-codex-parent-thread-id` header, together with every subagent it starts. Three count ceilings apply to each task, and requests over any of them are refused locally with HTTP 429 and an `x-opencodex-local-refusal` header naming the ceiling (`workflow_concurrency_exhausted`, `workflow_sends_exhausted` or `workflow_children_exhausted`); no provider is contacted. + +| Field | Default | Allowed | Meaning | +| --- | --- | --- | --- | +| `maxConcurrentChildren` | `8` | 2–64 | Turns one task may run at the same time. One slot is always reserved for the task's own conversation, so subagents may use one fewer. | +| `maxPhysicalSends` | `256` | 1–10000 | Upstream sends, retries included, after which the task's new requests are refused for the rest of the 10-minute window. The count is checked when a request is admitted, so retries inside a request that was already admitted, and requests admitted concurrently, can take the total past the ceiling. | +| `maxDistinctChildren` | `64` | 1–1024 | Distinct subagent threads one task may start inside the same window. | + +The defaults suit most installs. Raise them when a workflow that legitimately runs many subagents in parallel keeps hitting a ceiling; lower them to stop a runaway fan-out sooner: + +```jsonc +{ + "workflowBudget": { + "maxConcurrentChildren": 16, + "maxPhysicalSends": 1024 + } +} +``` + +The **Subagents** page of the dashboard shows the effective ceilings and edits them. `GET /api/workflow-budget/settings` returns the effective values with their defaults and allowed ranges; `PUT` with a partial object updates them, and JSON `null` restores the defaults. Dashboard/API changes apply to the next request without a restart and never reset what a task has already used; `POST /api/workflow-budget/clear` remains the way to forgive one task. A malformed file block is ignored with a warning and the built-in ceilings stay in force. Local `ocx config set/unset` edits require a restart to converge the running proxy. The 10-minute window, the reserved interactive slot and the 512 tracked tasks are not configurable. + ## Upstream transport recovery While the canonical ChatGPT upstream WebSocket waits for the first Responses event after diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 7da2cada27a..a85f352c5cb 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -271,13 +271,14 @@ See [Codex prompt layers](/guides/codex-prompt/) for the layer model and the key | `PUT /api/config` | Disabled full-config replacement guard | 405; use focused endpoints instead | | `GET, PUT /api/settings` | Read runtime/startup settings or update auto-start, stream mode, app-owned memory budget, and `codexAccountPickerEnabled` | 400 invalid, non-object, or empty update | | `GET, PUT /api/native-reasoning-retention` | Read resolved model/account reasoning-retention booleans; partial PUT preserves omitted fields, JSON `null` restores defaults. See [native reasoning retention](/reference/configuration/server/#native-reasoning-retention). | 400 unknown field, invalid type or non-object/non-null body | +| `GET, PUT /api/workflow-budget/settings` | Read the effective per-task workflow ceilings with defaults and allowed ranges; partial PUT preserves omitted fields, JSON `null` restores defaults. See [workflow limits](/reference/configuration/server/#workflow-limits). | 400 unknown field, non-integer or out-of-range value, or non-object/non-null body | | `GET /api/startup-health` | Read cached service/shim startup health | — | | `POST /api/startup-action` | Install or repair the service or Codex shim | 400 invalid action; 500 action failure | | `GET, POST /api/windows-tray` | Read Windows tray state or install/start/stop/uninstall it | 400 unsupported platform/action; 500 operation failure | | `GET /api/diagnostics/project-config` | Read cached project configuration warnings | — | | `POST /api/sync` | Sync the current model catalog into Codex | 500 failed sync | | `GET /api/update/check` | Asynchronously check the `latest` or `preview` package channel and refresh the package cache on success | 400 invalid tag | -| `POST /api/update/run` | Asynchronously check a fresh package version, then start an update job, optionally followed by restart | 400 invalid body; job-specific conflict/error status | +| `POST /api/update/run` | Asynchronously check a fresh package version, then start an update job, optionally followed by restart | 400 invalid body; 409 `update_restart_unsupervised` when the proxy runs inside a systemd unit other than OpenCodex's own user service, or the worker cannot leave the service cgroup (with or without restart, since the update stops the proxy first); job-specific conflict/error status | | `GET /api/update/status` | Poll an update job by id | 404 unknown job | | `GET, PUT /api/sidecar-settings` | Read or update web-search and vision sidecar model/backend settings | 400 invalid shape, backend, or limit | | `GET, PUT /api/shadow-call-settings` | Read or update shadow-call interception settings, including `sourceModels`; GET also returns the runtime-owned `sourceModelOptions` inventory | 400 invalid shape or value, including an empty, duplicate, whitespace-bearing, or provider-qualified source prefix, or more than 32 prefixes / 128 characters per prefix; source-list failures carry `code: shadow_call_source_invalid`, target conflicts `code: shadow_call_target_invalid` | diff --git a/docs-site/src/content/docs/ru/guides/combos.md b/docs-site/src/content/docs/ru/guides/combos.md index ccb5444da6f..5991f1a868c 100644 --- a/docs-site/src/content/docs/ru/guides/combos.md +++ b/docs-site/src/content/docs/ru/guides/combos.md @@ -80,6 +80,8 @@ Alias меняет публичное имя, которое запрашива ## Выбор стратегии +Цель, которая не может сохранить обязательный `tool_choice`, пропускается до отправки; если совместимых целей нет, возвращается ошибка с этим ограничением. Проверка учитывает модели Chat с выбором только `auto` и политику фактически отправляемой модели для виртуальных моделей OpenAI. Правила для прямого выбора hosted/MCP, `tool_search` и некорректных значений приведены в [каноническом руководстве на английском](/guides/combos/#failover-ordered-primary-and-backups). + ### Failover: основной вариант и резерв в заданном порядке `failover` выбирает первую подходящую цель в порядке конфигурации. Цель считается подходящей, diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index f8ef70c7a55..527f8f4761b 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -131,7 +131,7 @@ cross-route credential fallback не существует. Строки API GPT- | `models?` | `string[]` | Начальный список моделей или список для резервного режима. При `liveModels: false` за непустым `models` следует `retainModels`; если `models` пуст или отсутствует, сначала берётся настроенный `defaultModel`, затем `retainModels`. Для каждого ID сохраняется только первое вхождение. | | `liveModels?` | `boolean` | Получать live-каталог на start/sync (по умолчанию `true`). Custom-провайдеры используют `${baseUrl}/models`; built-in могут использовать registry URL и дополнительно фильтровать результат. | | `selectedModels?` | `string[]` | Allowlist каталога после discovery. Непустой список показывает только эти id; пустой или отсутствующий показывает всё, что было обнаружено. | -| `modelDisplayNames?` | `Record` | Постоянные display-only имена с точным нативным id модели этого провайдера в качестве ключа. Ключи чувствительны к регистру. Имена имеют приоритет над metadata каталога провайдера и не меняют аутентификацию, adapter, routing, billing или upstream-запросы. Карта содержит не более 2 000 записей, как и discovery. | +| `modelDisplayNames?` | `Record` | Постоянные display-only имена с точным нативным id модели этого провайдера в качестве ключа. Ключи чувствительны к регистру. Имена имеют приоритет над metadata каталога провайдера и не меняют аутентификацию, adapter, routing, billing или upstream-запросы. Исключение: если прямой селектор `provider/model` не совпадает ни с известным id, ни с alias, ни с `retainModels` (например, пока live-каталог ещё не загружен), ключ с совпадающей закодированной формой отправляется как нативный id. Карта содержит не более 2 000 записей, как и discovery. | | `contextWindow?` | `number` | Значение контекста для всего провайдера, применяемое когда upstream не отдаёт metadata; при наличии metadata работает как cap и сохраняет более маленькое live-значение. Панель Models настраивает его отдельно от `providerContextCaps`. | | `modelContextWindows?` | `Record` | Значения и cap'ы контекста по отдельным моделям. Перекрывают `contextWindow`: если окно неизвестно, берётся заданное значение, а более маленькая live-metadata остаётся авторитетной. | | `inferContextWindow?` | `boolean` | Только для пользовательских провайдеров, по умолчанию выключено. `true` заполняет отсутствующее окно из встроенных метаданных модели; это оценка, а не проверенная ёмкость посредника. | @@ -205,9 +205,9 @@ Claude Code настраивается через ### Что сохраняется при сохранении провайдера -`POST /api/providers` с именем существующего провайдера заменяет сохранённую строку строкой, собранной из запроса. Форма добавления и редактирования в панели не может отправить все поля, поэтому сохранение переносит часть сохранённых полей, которые запрос опустил. Десять из них описывают поведение конкретного апстрима: `preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`. +`POST /api/providers` с именем существующего провайдера заменяет сохранённую строку строкой, собранной из запроса. Форма добавления и редактирования в панели не может отправить все поля, поэтому сохранение переносит часть сохранённых полей, которые запрос опустил. Одиннадцать из них описывают поведение конкретного апстрима: `preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `promoteAdditionalTools`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`. -| Сохранение | Десять настроек | Сохранённый `apiKeyPool` | +| Сохранение | Одиннадцать настроек | Сохранённый `apiKeyPool` | | --- | --- | --- | | Та же точка назначения, поле опущено | Сохранённое значение остаётся, включая явный `[]` или `false` | Остаётся | | Новая точка назначения, поле опущено | Не переносится; могут примениться значения реестра по умолчанию для новой точки | Не переносится | diff --git a/docs-site/src/content/docs/tr/guides/chatgpt-desktop.md b/docs-site/src/content/docs/tr/guides/chatgpt-desktop.md index 847b14d27f0..b3dd5372fc3 100644 --- a/docs-site/src/content/docs/tr/guides/chatgpt-desktop.md +++ b/docs-site/src/content/docs/tr/guides/chatgpt-desktop.md @@ -101,10 +101,12 @@ etkisiz bir yeniden başlatma işlemi garanti etmez. ## Hata davranışı ve bilinen sınırlar -Platform macOS değilse, OpenCodex çalışma zamanı eksikse veya filtre öz sınaması -başarısızsa başlatıcı özgün dosyayı stdout'u değiştirmeden çalıştırır. Paketlenmiş -app-server dosyasının eksikliği istisnadır: geri dönülecek dosya olmadığı için -başlatıcı hatayla çıkar. +Başlatıcı, Desktop'ın yeniden başlattığı işlemler dahil her çalıştırmadan önce +yürütülebilir dosyayı doğrular. Stdout filtresi kullanılamıyorsa veya öz sınaması +başarısızsa doğrulanmış dosya stdout değiştirilmeden çalışır. Dosya doğrulaması +başarısızsa ya da OpenCodex doğrulayıcısı kullanılamıyorsa başlatıcı app-server'ı +çalıştırmadan hatayla çıkar. Paketlenmiş app-server dosyasının eksikliği de +başlatmayı engeller. Öz sınamayı geçen filtre oturum ortasında çökerse boru kapanır. Paketlenmiş app-server'ın bundan sonra ne yaptığı doğrulanmamıştır; SIGPIPE/yazma hatası @@ -115,8 +117,9 @@ ham baytlara geçirir. Deney; paketlenmiş dosya yoluna, uygulamanın `CODEX_CLI_PATH` değerini kullanmasına ve güncel RPC alan biçimlerine bağlıdır. Güncellemeler bunları değiştirebilir. -OpenCodex taşınır veya kaldırılırsa başlatıcının ön kontrolü başarısız olur ve -özgün dosya çalışır. Kurulumu taşıdıktan sonra `ocx chatgpt launch` komutunu yeniden çalıştırın. +OpenCodex taşınır veya kaldırılırsa başlatıcı dosyayı doğrulayamayabilir ve +başlatmayı reddeder. Kurulumu taşıdıktan sonra `ocx chatgpt launch` komutunu yeniden +çalıştırın veya geçersiz kılmayı kaldırmak için `ocx chatgpt restore` kullanın. Uygulama güncellemesi paketlenmiş app-server dosyasını taşır veya kaldırırsa başlatıcı onu çalıştıramaz: stderr üzerinde `ocx chatgpt launch` ve diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index c13c0c45116..12f2004ed8b 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -205,9 +205,9 @@ yapılandırılır. ### Bir sağlayıcı kaydı neleri korur -Var olan bir sağlayıcının adıyla gönderilen `POST /api/providers`, saklanan satırı istekten oluşturulan bir satırla değiştirir. Panodaki ekleme/düzenleme formu her alanı gönderemez; bu yüzden kayıt, isteğin atladığı bazı saklanan alanları korur. Bunlardan onu belirli bir üst kaynağın nasıl davrandığını kaydeder: `preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`. +Var olan bir sağlayıcının adıyla gönderilen `POST /api/providers`, saklanan satırı istekten oluşturulan bir satırla değiştirir. Panodaki ekleme/düzenleme formu her alanı gönderemez; bu yüzden kayıt, isteğin atladığı bazı saklanan alanları korur. Bunlardan on biri belirli bir üst kaynağın nasıl davrandığını kaydeder: `preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `promoteAdditionalTools`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`. -| Kayıt | On ayar | Saklanan `apiKeyPool` | +| Kayıt | On bir ayar | Saklanan `apiKeyPool` | | --- | --- | --- | | Aynı hedef, alan atlanmış | Saklanan değer korunur, açık bir `[]` veya `false` dahil | Korunur | | Yeni hedef, alan atlanmış | Korunmaz; yeni hedef için kayıt defteri varsayılanları uygulanabilir | Korunmaz | diff --git a/docs-site/src/content/docs/zh-cn/guides/combos.md b/docs-site/src/content/docs/zh-cn/guides/combos.md index 5244fe9b646..7262139cb71 100644 --- a/docs-site/src/content/docs/zh-cn/guides/combos.md +++ b/docs-site/src/content/docs/zh-cn/guides/combos.md @@ -90,6 +90,8 @@ modality 和 reasoning metadata;目标显式声明的限制仍然优先,因 ## 选择策略 +无法保留强制 `tool_choice` 的目标会在发送前跳过;如果所有目标都不兼容,则返回说明该限制的错误。判断会考虑仅支持 `auto` 选择的 Chat 模型,以及 OpenAI 虚拟模型实际发送到上游的模型策略。直接 hosted/MCP 选择、`tool_search` 和无效选择值的处理范围请参阅[英文基准说明](/guides/combos/#failover-ordered-primary-and-backups)。 + ### 故障切换:按顺序的主目标和备用目标 `failover` 会按配置顺序选择第一个合格目标。当 provider 存在、已启用、未处于冷却中,并且能够满足任何特殊请求约束时,该目标就是合格的。权重和 `stickyLimit` 不影响这种策略。 diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index 2703729ead1..bc201ce0b73 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -120,7 +120,7 @@ selector,而不是分配一个新名称。 | `models?` | `string[]` | 初始/回退模型列表。`liveModels: false` 时,非空 `models` 后接 `retainModels`;若 `models` 为空或省略,则按已配置的 `defaultModel`、`retainModels` 顺序构建初始列表,重复 ID 仅保留首次出现的位置。 | | `liveModels?` | `boolean` | 启动/同步时获取实时目录(默认 `true`)。自定义提供者使用 `${baseUrl}/models`;内置项可能使用注册表 URL 并进行过滤。 | | `selectedModels?` | `string[]` | 发现之后的目录允许列表。非空时只暴露这些 id;为空或省略时则暴露全部发现到的模型。 | -| `modelDisplayNames?` | `Record` | 持久的仅显示名称,以此提供者的精确原生模型 id 为键。键区分大小写。名称优先于提供者目录元数据,并且不会改变身份验证、适配器、路由、计费或上游请求。该映射最多可包含 2,000 个条目,与发现上限相同。 | +| `modelDisplayNames?` | `Record` | 持久的仅显示名称,以此提供者的精确原生模型 id 为键。键区分大小写。名称优先于提供者目录元数据,并且不会改变身份验证、适配器、路由、计费或上游请求。例外:当直接的 `provider/model` 选择器不匹配任何已知 id、别名或 `retainModels`(例如实时目录尚未获取时),编码形式相同的键会作为原生 id 发送。该映射最多可包含 2,000 个条目,与发现上限相同。 | | `contextWindow?` | `number` | 上游缺少元数据时使用的提供者级上下文数值;有元数据时作为上限,保留更小的实时数值。Models 面板中与 `providerContextCaps` 分开设置。 | | `modelContextWindows?` | `Record` | 按模型设置的上下文数值与上限。优先于 `contextWindow`:窗口未知时采用所配置的数值,而更小的实时元数据仍然优先。 | | `inferContextWindow?` | `boolean` | 仅限自定义渠道,默认关闭。设为 `true` 时用内置模型元数据补全缺失窗口;这是估计值,不代表中转的实测容量。 | @@ -191,9 +191,9 @@ API key 提供者可以持有字面量 key,或环境引用。OAuth 提供者 ### 保存提供方时会保留什么 -用已有提供方的名称调用 `POST /api/providers`,会用根据请求构建的行替换已存储的行。仪表板的添加/编辑表单无法发送所有字段,因此保存时会保留请求省略的部分已存储字段。其中十个记录的是某个上游的行为:`preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`。 +用已有提供方的名称调用 `POST /api/providers`,会用根据请求构建的行替换已存储的行。仪表板的添加/编辑表单无法发送所有字段,因此保存时会保留请求省略的部分已存储字段。其中十一个记录的是某个上游的行为:`preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `promoteAdditionalTools`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`。 -| 保存 | 十项设置 | 已存储的 `apiKeyPool` | +| 保存 | 十一项设置 | 已存储的 `apiKeyPool` | | --- | --- | --- | | 目的地相同,字段省略 | 保留已存储的值,包括显式的 `[]` 或 `false` | 保留 | | 新目的地,字段省略 | 不保留;可能套用新目的地的注册表默认值 | 不保留 | diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index cc8b93b3735..5a430f66c07 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -162,9 +162,9 @@ API-key 供應商可持有字面值金鑰或環境參考。OAuth 供應商使用 ### 儲存供應商時會保留什麼 -以既有供應商的名稱呼叫 `POST /api/providers`,會以根據請求建立的列取代已儲存的列。儀表板的新增/編輯表單無法傳送所有欄位,因此儲存時會保留請求省略的部分已儲存欄位。其中十個記錄的是某個上游的行為:`preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`。 +以既有供應商的名稱呼叫 `POST /api/providers`,會以根據請求建立的列取代已儲存的列。儀表板的新增/編輯表單無法傳送所有欄位,因此儲存時會保留請求省略的部分已儲存欄位。其中十一個記錄的是某個上游的行為:`preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `promoteAdditionalTools`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`。 -| 儲存 | 十項設定 | 已儲存的 `apiKeyPool` | +| 儲存 | 十一項設定 | 已儲存的 `apiKeyPool` | | --- | --- | --- | | 目的地相同,欄位省略 | 保留已儲存的值,包括明確的 `[]` 或 `false` | 保留 | | 新目的地,欄位省略 | 不保留;可能套用新目的地的登錄檔預設值 | 不保留 | diff --git a/gui/src/codex-quota-utils.ts b/gui/src/codex-quota-utils.ts index 191f61ae866..0dcfcba023c 100644 --- a/gui/src/codex-quota-utils.ts +++ b/gui/src/codex-quota-utils.ts @@ -1,4 +1,4 @@ -import { isTerminalShortWindow } from "../../src/codex/quota-types"; +import { hasElapsedShortWindow, isTerminalShortWindow } from "../../src/codex/quota-types"; export interface AccountQuota { workbuddyCredits?: import("../../src/providers/workbuddy-credit-balance").WorkBuddyCreditBalance; @@ -66,7 +66,7 @@ export function normalizeQuotaForPlan(quota: AccountQuota | null, plan: string | * Evaluates governing quota windows based on the account's plan: * - For 30-day only plans (e.g. Free/Go), only the monthly window governs. * - For standard plans, weekly and monthly windows govern. - * - A known five-hour / short window refines a known governing long-window score. + * - A known five-hour / short window refines a known governing long-window score until its valid reset elapses. * - If no long window has been observed, an active terminal short burst (at 100%) acts as exhausted (100). * - Unknown or unprimed quota returns `null` so callers do not spuriously trigger threshold actions. */ @@ -104,6 +104,7 @@ export function computeCodexUsageScore( ? 100 : null; } - const values = finite(shortPercent) ? [...knownLong, shortPercent] : knownLong; + const values = finite(shortPercent) && !hasElapsedShortWindow(quota.fiveHourResetAt ?? quota.shortResetAt, now) + ? [...knownLong, shortPercent] : knownLong; return values.length ? Math.max(...values) : null; } diff --git a/gui/src/components/WorkflowBudgetPanel.tsx b/gui/src/components/WorkflowBudgetPanel.tsx new file mode 100644 index 00000000000..e8d7cb29e25 --- /dev/null +++ b/gui/src/components/WorkflowBudgetPanel.tsx @@ -0,0 +1,178 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useT } from "../i18n/shared"; +import { createBoundedFetch } from "../bounded-fetch"; +import { clampNumberDraft } from "../clamp-draft"; +import { requireJson } from "../pages/dashboard-shared"; +import { NumberStepper } from "./NumberStepper"; + +const KEYS = ["maxConcurrentChildren", "maxPhysicalSends", "maxDistinctChildren"] as const; +type Key = typeof KEYS[number]; +type Values = Record; +type Drafts = Record; +interface Settings { + values: Values; + defaults: Values; + bounds: Record; + windowMinutes: number; +} + +const LABEL_KEYS = { + maxConcurrentChildren: ["workflowBudget.maxConcurrentChildren", "workflowBudget.maxConcurrentChildrenHint"], + maxPhysicalSends: ["workflowBudget.maxPhysicalSends", "workflowBudget.maxPhysicalSendsHint"], + maxDistinctChildren: ["workflowBudget.maxDistinctChildren", "workflowBudget.maxDistinctChildrenHint"], +} as const; + +const isInt = (value: unknown): value is number => typeof value === "number" && Number.isInteger(value); + +function readSettings(payload: unknown): Settings { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) throw new Error("Invalid workflow budget settings"); + const value = payload as Record; + const defaults = value.defaults as Record | undefined; + const bounds = value.bounds as Record | undefined; + if (!defaults || !bounds || !isInt(value.windowMs)) throw new Error("Missing workflow budget settings"); + const settings = { values: {}, defaults: {}, bounds: {}, windowMinutes: Math.round(value.windowMs / 60_000) } as Settings; + for (const key of KEYS) { + const range = bounds[key]; + if (!isInt(value[key]) || !isInt(defaults[key]) || !range || !isInt(range.min) || !isInt(range.max)) { + throw new Error("Missing workflow budget settings"); + } + settings.values[key] = value[key]; + settings.defaults[key] = defaults[key]; + settings.bounds[key] = { min: range.min, max: range.max }; + } + return settings; +} + +const toDrafts = (values: Values): Drafts => + ({ maxConcurrentChildren: String(values.maxConcurrentChildren), maxPhysicalSends: String(values.maxPhysicalSends), maxDistinctChildren: String(values.maxDistinctChildren) }); + +function parseDraft(raw: string, range: { min: number; max: number }): number | undefined { + if (!/^\d+$/.test(raw.trim())) return undefined; + const value = Number(raw.trim()); + return value >= range.min && value <= range.max ? value : undefined; +} + +export default function WorkflowBudgetPanel({ apiBase }: { apiBase: string }) { + return ; +} + +function WorkflowBudgetControls({ apiBase }: { apiBase: string }) { + const t = useT(); + const [saved, setSaved] = useState(); + const [drafts, setDrafts] = useState({ maxConcurrentChildren: "", maxPhysicalSends: "", maxDistinctChildren: "" }); + const [busy, setBusy] = useState(false); + const [loadError, setLoadError] = useState(false); + const [feedback, setFeedback] = useState<"saved" | "failed" | null>(null); + const active = useRef(false); + const pending = useRef | null>(null); + const accept = useCallback((value: Settings) => { setSaved(value); setDrafts(toDrafts(value.values)); }, []); + const load = useCallback(async () => { + if (pending.current) return; + const request = createBoundedFetch(15_000); + pending.current = request; setLoadError(false); + try { + const response = await fetch(apiBase + "/api/workflow-budget/settings", { signal: request.signal }); + const value = readSettings(await requireJson(response)); + if (active.current && pending.current === request) accept(value); + } catch { + if (active.current && pending.current === request) setLoadError(true); + } finally { + request.clear(); + if (pending.current === request) pending.current = null; + } + }, [apiBase, accept]); + useEffect(() => { + active.current = true; + const timer = window.setTimeout(() => { void load(); }, 0); + return () => { + window.clearTimeout(timer); active.current = false; + pending.current?.controller.abort(); pending.current?.clear(); pending.current = null; + }; + }, [load]); + + const parsed = saved + ? Object.fromEntries(KEYS.map(key => [key, parseDraft(drafts[key], saved.bounds[key])])) as Record + : undefined; + const valid = parsed !== undefined && KEYS.every(key => parsed[key] !== undefined); + const changed = saved !== undefined && parsed !== undefined && KEYS.some(key => parsed[key] !== saved.values[key]); + const atDefaults = saved !== undefined && KEYS.every(key => saved.values[key] === saved.defaults[key]); + + const save = async (reset = false) => { + if (pending.current || saved === undefined || (!reset && (!valid || parsed === undefined))) return; + // Values equal to the defaults are written as a reset, so the install keeps following the + // built-in ceilings instead of pinning today's numbers. + const body = reset || KEYS.every(key => parsed?.[key] === saved.defaults[key]) + ? null + : Object.fromEntries(KEYS.map(key => [key, parsed?.[key]])); + const request = createBoundedFetch(15_000); + pending.current = request; setBusy(true); setFeedback(null); + try { + const response = await fetch(apiBase + "/api/workflow-budget/settings", { + method: "PUT", headers: { "Content-Type": "application/json" }, signal: request.signal, + body: JSON.stringify(body), + }); + const value = readSettings(await requireJson(response)); + if (active.current && pending.current === request) { accept(value); setFeedback("saved"); } + } catch { + if (active.current && pending.current === request) setFeedback("failed"); + } finally { + request.clear(); + if (active.current && pending.current === request) setBusy(false); + if (pending.current === request) pending.current = null; + } + }; + const disabled = busy || saved === undefined || loadError; + const edit = (key: Key, value: string) => { setDrafts(current => ({ ...current, [key]: value })); setFeedback(null); }; + + return
+
{t("workflowBudget.title")}
+

{t("workflowBudget.description", { minutes: saved?.windowMinutes ?? 10 })}

+
+ {KEYS.map(key => { + const [labelKey, hintKey] = LABEL_KEYS[key]; + const label = t(labelKey); + const range = saved?.bounds[key] ?? { min: 0, max: 0 }; + const invalid = saved !== undefined && parsed?.[key] === undefined; + const hintId = "workflow-budget-" + key + "-hint"; + return
+
+ {label} + + {t(hintKey)}{" "} + {saved && t("workflowBudget.range", { default: saved.defaults[key], min: range.min, max: range.max })} + + {invalid && {t("workflowBudget.invalid", { min: range.min, max: range.max })}} +
+ + edit(key, event.target.value)} + onKeyDown={event => { + if (event.key === "Enter" && !event.nativeEvent.isComposing && !disabled && changed && valid) { + event.preventDefault(); void save(); + } + }} + /> + edit(key, clampNumberDraft(drafts[key], 1, range.min, range.max))} + onDecrement={() => edit(key, clampNumberDraft(drafts[key], -1, range.min, range.max))} + /> + +
; + })} +
+ + +
+
+ {loadError &&
{t("workflowBudget.loadFailed")}
} + {feedback === "failed" &&
{t("workflowBudget.saveFailed")}
} + {feedback === "saved" &&
{t("workflowBudget.saved")}
} +
; +} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index fffab7207a4..affe092dcec 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -105,6 +105,22 @@ export const de: Record = { "nativeReasoningRetention.loadFailed": "Native Denkdateneinstellungen konnten nicht geladen werden.", "nativeReasoningRetention.saveFailed": "Native Denkdateneinstellungen konnten nicht gespeichert werden.", "nativeReasoningRetention.saved": "Native Denkdateneinstellungen gespeichert.", + "workflowBudget.title": "Workflow-Limits für Subagenten", + "workflowBudget.description": "Obergrenzen pro Aufgabe, also pro Unterhaltung samt der Subagenten, die sie startet, gezählt über ein gleitendes Fenster von {minutes} Minuten. Eine Anfrage über einer Grenze wird lokal mit 429 abgelehnt, bevor ein Anbieter kontaktiert wird. Änderungen gelten ab der nächsten Anfrage; bereits gezählte Nutzung bleibt erhalten.", + "workflowBudget.maxConcurrentChildren": "Parallele Turns", + "workflowBudget.maxConcurrentChildrenHint": "Turns, die eine Aufgabe gleichzeitig ausführen darf. Ein Platz bleibt immer für die eigene Unterhaltung reserviert, Subagenten bekommen also einen weniger.", + "workflowBudget.maxPhysicalSends": "Modellanfragen pro Fenster", + "workflowBudget.maxPhysicalSendsHint": "Anfragen an den Anbieter, Wiederholungen eingeschlossen, die eine Aufgabe im Fenster senden darf.", + "workflowBudget.maxDistinctChildren": "Verschiedene Subagenten pro Fenster", + "workflowBudget.maxDistinctChildrenHint": "Unterschiedliche Subagenten-Threads, die eine Aufgabe im Fenster starten darf.", + "workflowBudget.range": "Standard {default}, erlaubt {min}–{max}.", + "workflowBudget.invalid": "Bitte eine ganze Zahl von {min} bis {max} eingeben.", + "workflowBudget.increase": "{label} erhöhen", + "workflowBudget.decrease": "{label} verringern", + "workflowBudget.reset": "Standard wiederherstellen", + "workflowBudget.loadFailed": "Workflow-Limits konnten nicht geladen werden.", + "workflowBudget.saveFailed": "Workflow-Limits konnten nicht gespeichert werden.", + "workflowBudget.saved": "Workflow-Limits gespeichert.", ...desktopCompatibilityCopy("de"), "claude.tierDefaults.unset": "Nicht festgelegt (Claude-Einstellung beibehalten)", "claude.tierDefaults.recovery": "Anwendung gestoppt; automatische Wiederherstellung fehlgeschlagen. Prüfen Sie vor einem erneuten Versuch die Sicherung unten.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index d805772ff80..4f4cd4ffe0f 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -106,6 +106,22 @@ export const en = { "nativeReasoningRetention.loadFailed": "Could not load native reasoning settings.", "nativeReasoningRetention.saveFailed": "Could not save native reasoning settings.", "nativeReasoningRetention.saved": "Native reasoning settings saved.", + "workflowBudget.title": "Workflow limits for subagents", + "workflowBudget.description": "Ceilings per task, meaning one conversation and the subagents it starts, counted over a rolling {minutes}-minute window. A request over a ceiling is refused locally with a 429 before any provider is contacted. Changes apply to the next request; usage already counted is kept.", + "workflowBudget.maxConcurrentChildren": "Parallel turns", + "workflowBudget.maxConcurrentChildrenHint": "Turns one task may run at the same time. One slot is always kept for your own conversation, so subagents get one fewer.", + "workflowBudget.maxPhysicalSends": "Model requests per window", + "workflowBudget.maxPhysicalSendsHint": "Upstream requests, retries included, one task may send inside the window.", + "workflowBudget.maxDistinctChildren": "Different subagents per window", + "workflowBudget.maxDistinctChildrenHint": "Distinct subagent threads one task may start inside the window.", + "workflowBudget.range": "Default {default}, allowed {min}–{max}.", + "workflowBudget.invalid": "Enter a whole number from {min} to {max}.", + "workflowBudget.increase": "Increase {label}", + "workflowBudget.decrease": "Decrease {label}", + "workflowBudget.reset": "Restore defaults", + "workflowBudget.loadFailed": "Could not load workflow limits.", + "workflowBudget.saveFailed": "Could not save workflow limits.", + "workflowBudget.saved": "Workflow limits saved.", ...desktopCompatibilityCopy("en"), "claude.tierDefaults.unset": "Unset (keep existing Claude setting)", "claude.tierDefaults.recovery": "Apply stopped and automatic restore failed. Check the backup below before retrying.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index f3fbc99a28c..4b8cf58b1de 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -104,6 +104,22 @@ export const fr: Record = { "nativeReasoningRetention.loadFailed": "Impossible de charger les paramètres du raisonnement natif.", "nativeReasoningRetention.saveFailed": "Impossible de sauvegarder les paramètres du raisonnement natif.", "nativeReasoningRetention.saved": "Paramètres du raisonnement natif sauvegardés.", + "workflowBudget.title": "Limites de workflow des sous-agents", + "workflowBudget.description": "Plafonds par tâche, c'est-à-dire une conversation et les sous-agents qu'elle lance, comptés sur une fenêtre glissante de {minutes} minutes. Une requête au-delà d'un plafond est refusée localement avec un 429 avant tout contact avec un fournisseur. Les changements s'appliquent à la requête suivante ; l'usage déjà compté est conservé.", + "workflowBudget.maxConcurrentChildren": "Tours en parallèle", + "workflowBudget.maxConcurrentChildrenHint": "Tours qu'une tâche peut exécuter en même temps. Un emplacement reste toujours réservé à votre propre conversation, les sous-agents en ont donc un de moins.", + "workflowBudget.maxPhysicalSends": "Requêtes au modèle par fenêtre", + "workflowBudget.maxPhysicalSendsHint": "Requêtes vers le fournisseur, nouvelles tentatives comprises, qu'une tâche peut envoyer dans la fenêtre.", + "workflowBudget.maxDistinctChildren": "Sous-agents distincts par fenêtre", + "workflowBudget.maxDistinctChildrenHint": "Fils de sous-agents différents qu'une tâche peut lancer dans la fenêtre.", + "workflowBudget.range": "Par défaut {default}, autorisé {min}–{max}.", + "workflowBudget.invalid": "Saisissez un nombre entier de {min} à {max}.", + "workflowBudget.increase": "Augmenter : {label}", + "workflowBudget.decrease": "Diminuer : {label}", + "workflowBudget.reset": "Restaurer les valeurs par défaut", + "workflowBudget.loadFailed": "Impossible de charger les limites de workflow.", + "workflowBudget.saveFailed": "Impossible d'enregistrer les limites de workflow.", + "workflowBudget.saved": "Limites de workflow enregistrées.", ...desktopCompatibilityCopy("fr"), "claude.tierDefaults.unset": "Non défini (conserver le réglage Claude)", "claude.tierDefaults.recovery": "Application arrêtée et restauration automatique échouée. Vérifiez la sauvegarde ci-dessous avant de réessayer.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index a3b6940897f..4c99bb76162 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -104,6 +104,22 @@ export const ja: Record = { "nativeReasoningRetention.loadFailed": "ネイティブ推論の設定を読み込めませんでした。", "nativeReasoningRetention.saveFailed": "ネイティブ推論の設定を保存できませんでした。", "nativeReasoningRetention.saved": "ネイティブ推論の設定を保存しました。", + "workflowBudget.title": "サブエージェントのワークフロー上限", + "workflowBudget.description": "タスク(1つの会話とそこから起動されたサブエージェント)ごとの上限で、直近{minutes}分間のスライディングウィンドウで数えます。上限を超えたリクエストは、プロバイダーに接続する前にローカルで 429 として拒否されます。変更は次のリクエストから適用され、すでに数えられた使用量は保持されます。", + "workflowBudget.maxConcurrentChildren": "並列ターン数", + "workflowBudget.maxConcurrentChildrenHint": "1つのタスクが同時に実行できるターン数です。1枠は常に自分の会話用に確保されるため、サブエージェントが使えるのは1つ少なくなります。", + "workflowBudget.maxPhysicalSends": "ウィンドウあたりのモデルリクエスト数", + "workflowBudget.maxPhysicalSendsHint": "1つのタスクがウィンドウ内に送信できるプロバイダーへのリクエスト数(再試行を含む)です。", + "workflowBudget.maxDistinctChildren": "ウィンドウあたりの異なるサブエージェント数", + "workflowBudget.maxDistinctChildrenHint": "1つのタスクがウィンドウ内に起動できる異なるサブエージェントスレッドの数です。", + "workflowBudget.range": "既定値 {default}、範囲 {min}–{max}。", + "workflowBudget.invalid": "{min}~{max} の整数を入力してください。", + "workflowBudget.increase": "{label}を増やす", + "workflowBudget.decrease": "{label}を減らす", + "workflowBudget.reset": "既定値に戻す", + "workflowBudget.loadFailed": "ワークフロー上限を読み込めませんでした。", + "workflowBudget.saveFailed": "ワークフロー上限を保存できませんでした。", + "workflowBudget.saved": "ワークフロー上限を保存しました。", ...desktopCompatibilityCopy("ja"), "claude.tierDefaults.unset": "未指定(既存のClaude設定を維持)", "claude.tierDefaults.recovery": "適用が停止し、自動復元に失敗しました。再試行する前に以下のバックアップを確認してください。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index a92e751b06b..b073cfed72c 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -104,6 +104,22 @@ export const ko: Record = { "nativeReasoningRetention.loadFailed": "네이티브 추론 설정을 불러올 수 없습니다.", "nativeReasoningRetention.saveFailed": "네이티브 추론 설정을 저장할 수 없습니다.", "nativeReasoningRetention.saved": "네이티브 추론 설정을 저장했습니다.", + "workflowBudget.title": "서브에이전트 워크플로 한도", + "workflowBudget.description": "작업(하나의 대화와 그 대화가 시작한 서브에이전트)마다 적용되는 상한으로, 최근 {minutes}분의 이동 창 기준으로 집계합니다. 상한을 넘는 요청은 공급자에 연결하기 전에 로컬에서 429로 거부됩니다. 변경 사항은 다음 요청부터 적용되며, 이미 집계된 사용량은 유지됩니다.", + "workflowBudget.maxConcurrentChildren": "병렬 턴", + "workflowBudget.maxConcurrentChildrenHint": "하나의 작업이 동시에 실행할 수 있는 턴 수입니다. 한 자리는 항상 내 대화용으로 남겨 두므로 서브에이전트는 하나 적게 사용합니다.", + "workflowBudget.maxPhysicalSends": "창당 모델 요청 수", + "workflowBudget.maxPhysicalSendsHint": "하나의 작업이 창 안에서 보낼 수 있는 공급자 요청 수(재시도 포함)입니다.", + "workflowBudget.maxDistinctChildren": "창당 서로 다른 서브에이전트 수", + "workflowBudget.maxDistinctChildrenHint": "하나의 작업이 창 안에서 시작할 수 있는 서로 다른 서브에이전트 스레드 수입니다.", + "workflowBudget.range": "기본값 {default}, 허용 범위 {min}–{max}.", + "workflowBudget.invalid": "{min}~{max} 사이의 정수를 입력하세요.", + "workflowBudget.increase": "{label} 늘리기", + "workflowBudget.decrease": "{label} 줄이기", + "workflowBudget.reset": "기본값 복원", + "workflowBudget.loadFailed": "워크플로 한도를 불러오지 못했습니다.", + "workflowBudget.saveFailed": "워크플로 한도를 저장하지 못했습니다.", + "workflowBudget.saved": "워크플로 한도를 저장했습니다.", ...desktopCompatibilityCopy("ko"), "claude.tierDefaults.unset": "미지정 (기존 Claude 설정 유지)", "claude.tierDefaults.recovery": "적용이 중단되었고 자동 복원에 실패했어요. 다시 시도하기 전에 아래 백업을 확인하세요.", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index 526567b7eba..f170560e262 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -106,6 +106,22 @@ export const pt: Record = { "nativeReasoningRetention.loadFailed": "Não foi possível carregar as configurações de raciocínio nativo.", "nativeReasoningRetention.saveFailed": "Não foi possível salvar as configurações de raciocínio nativo.", "nativeReasoningRetention.saved": "Configurações de raciocínio nativo salvas.", + "workflowBudget.title": "Limites de fluxo de trabalho dos subagentes", + "workflowBudget.description": "Tetos por tarefa, ou seja, uma conversa e os subagentes que ela inicia, contados em uma janela deslizante de {minutes} minutos. Uma requisição acima de um teto é recusada localmente com 429 antes de qualquer provedor ser contatado. As alterações valem a partir da próxima requisição; o uso já contado é mantido.", + "workflowBudget.maxConcurrentChildren": "Turnos em paralelo", + "workflowBudget.maxConcurrentChildrenHint": "Turnos que uma tarefa pode executar ao mesmo tempo. Uma vaga fica sempre reservada para a sua própria conversa, então os subagentes recebem uma a menos.", + "workflowBudget.maxPhysicalSends": "Requisições ao modelo por janela", + "workflowBudget.maxPhysicalSendsHint": "Requisições ao provedor, incluindo novas tentativas, que uma tarefa pode enviar dentro da janela.", + "workflowBudget.maxDistinctChildren": "Subagentes diferentes por janela", + "workflowBudget.maxDistinctChildrenHint": "Threads de subagentes distintos que uma tarefa pode iniciar dentro da janela.", + "workflowBudget.range": "Padrão {default}, permitido {min}–{max}.", + "workflowBudget.invalid": "Informe um número inteiro de {min} a {max}.", + "workflowBudget.increase": "Aumentar {label}", + "workflowBudget.decrease": "Diminuir {label}", + "workflowBudget.reset": "Restaurar padrões", + "workflowBudget.loadFailed": "Não foi possível carregar os limites de fluxo de trabalho.", + "workflowBudget.saveFailed": "Não foi possível salvar os limites de fluxo de trabalho.", + "workflowBudget.saved": "Limites de fluxo de trabalho salvos.", ...desktopCompatibilityCopy("pt"), "claude.tierDefaults.unset": "Não definido (manter configuração Claude)", "claude.tierDefaults.recovery": "Aplicação interrompida; restauração automática falhou. Verifique o backup abaixo antes de tentar novamente.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 5835b18f727..7c7d951edde 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -104,6 +104,22 @@ export const ru: Record = { "nativeReasoningRetention.loadFailed": "Не удалось загрузить настройки нативных рассуждений.", "nativeReasoningRetention.saveFailed": "Не удалось сохранить настройки нативных рассуждений.", "nativeReasoningRetention.saved": "Настройки нативных рассуждений сохранены.", + "workflowBudget.title": "Лимиты рабочих процессов для субагентов", + "workflowBudget.description": "Ограничения на задачу, то есть на один диалог и запущенных им субагентов, считаются в скользящем окне длиной {minutes} минут. Запрос сверх ограничения отклоняется локально с кодом 429 ещё до обращения к провайдеру. Изменения действуют со следующего запроса; уже учтённое использование сохраняется.", + "workflowBudget.maxConcurrentChildren": "Параллельные ходы", + "workflowBudget.maxConcurrentChildrenHint": "Сколько ходов одна задача может выполнять одновременно. Одно место всегда остаётся за вашим собственным диалогом, поэтому субагентам доступно на одно меньше.", + "workflowBudget.maxPhysicalSends": "Запросов к модели за окно", + "workflowBudget.maxPhysicalSendsHint": "Запросы к провайдеру, включая повторные попытки, которые одна задача может отправить за окно.", + "workflowBudget.maxDistinctChildren": "Разных субагентов за окно", + "workflowBudget.maxDistinctChildrenHint": "Сколько разных потоков субагентов одна задача может запустить за окно.", + "workflowBudget.range": "По умолчанию {default}, допустимо {min}–{max}.", + "workflowBudget.invalid": "Введите целое число от {min} до {max}.", + "workflowBudget.increase": "Увеличить: {label}", + "workflowBudget.decrease": "Уменьшить: {label}", + "workflowBudget.reset": "Восстановить значения по умолчанию", + "workflowBudget.loadFailed": "Не удалось загрузить лимиты рабочих процессов.", + "workflowBudget.saveFailed": "Не удалось сохранить лимиты рабочих процессов.", + "workflowBudget.saved": "Лимиты рабочих процессов сохранены.", ...desktopCompatibilityCopy("ru"), "claude.tierDefaults.unset": "Не задано (сохранить настройку Claude)", "claude.tierDefaults.recovery": "Применение остановлено, автоматическое восстановление не удалось. Проверьте резервную копию ниже перед повтором.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index b673ee21a0f..ab1fd6ee3b1 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -105,6 +105,22 @@ export const tr: Record = { "nativeReasoningRetention.loadFailed": "Yerel akıl yürütme ayarları yüklenemedi.", "nativeReasoningRetention.saveFailed": "Yerel akıl yürütme ayarları kaydedilemedi.", "nativeReasoningRetention.saved": "Yerel akıl yürütme ayarları kaydedildi.", + "workflowBudget.title": "Alt ajanlar için iş akışı sınırları", + "workflowBudget.description": "Görev başına, yani bir konuşma ve başlattığı alt ajanlar için üst sınırlar; {minutes} dakikalık kayan bir pencerede sayılır. Bir sınırı aşan istek, herhangi bir sağlayıcıyla iletişime geçilmeden önce yerel olarak 429 ile reddedilir. Değişiklikler bir sonraki istekten itibaren geçerlidir; önceden sayılan kullanım korunur.", + "workflowBudget.maxConcurrentChildren": "Paralel turlar", + "workflowBudget.maxConcurrentChildrenHint": "Bir görevin aynı anda çalıştırabileceği tur sayısı. Bir yer her zaman kendi konuşmanız için ayrılır, bu yüzden alt ajanlar bir eksik alır.", + "workflowBudget.maxPhysicalSends": "Pencere başına model isteği", + "workflowBudget.maxPhysicalSendsHint": "Bir görevin pencere içinde gönderebileceği sağlayıcı istekleri, yeniden denemeler dahil.", + "workflowBudget.maxDistinctChildren": "Pencere başına farklı alt ajan", + "workflowBudget.maxDistinctChildrenHint": "Bir görevin pencere içinde başlatabileceği farklı alt ajan iş parçacığı sayısı.", + "workflowBudget.range": "Varsayılan {default}, izin verilen {min}–{max}.", + "workflowBudget.invalid": "{min} ile {max} arasında bir tam sayı girin.", + "workflowBudget.increase": "{label} artır", + "workflowBudget.decrease": "{label} azalt", + "workflowBudget.reset": "Varsayılanları geri yükle", + "workflowBudget.loadFailed": "İş akışı sınırları yüklenemedi.", + "workflowBudget.saveFailed": "İş akışı sınırları kaydedilemedi.", + "workflowBudget.saved": "İş akışı sınırları kaydedildi.", ...desktopCompatibilityCopy("tr"), "claude.tierDefaults.unset": "Belirtilmedi (mevcut Claude ayarını koru)", "claude.tierDefaults.recovery": "Uygulama durdu ve otomatik geri yükleme başarısız oldu. Yeniden denemeden önce aşağıdaki yedeği kontrol edin.", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index eed86f0d3d2..807a752f49e 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -106,6 +106,22 @@ export const vi: Record = { "nativeReasoningRetention.loadFailed": "Không thể tải cài đặt suy luận gốc.", "nativeReasoningRetention.saveFailed": "Không thể lưu cài đặt suy luận gốc.", "nativeReasoningRetention.saved": "Đã lưu cài đặt suy luận gốc.", + "workflowBudget.title": "Giới hạn quy trình cho tác tử phụ", + "workflowBudget.description": "Mức trần cho mỗi tác vụ, tức là một cuộc trò chuyện cùng các tác tử phụ nó khởi chạy, được đếm trong cửa sổ trượt {minutes} phút. Yêu cầu vượt mức trần bị từ chối cục bộ với mã 429 trước khi liên hệ bất kỳ nhà cung cấp nào. Thay đổi áp dụng từ yêu cầu tiếp theo; mức sử dụng đã đếm được giữ nguyên.", + "workflowBudget.maxConcurrentChildren": "Lượt chạy song song", + "workflowBudget.maxConcurrentChildrenHint": "Số lượt một tác vụ có thể chạy cùng lúc. Luôn giữ lại một chỗ cho cuộc trò chuyện của bạn, nên tác tử phụ được ít hơn một.", + "workflowBudget.maxPhysicalSends": "Yêu cầu tới mô hình mỗi cửa sổ", + "workflowBudget.maxPhysicalSendsHint": "Số yêu cầu tới nhà cung cấp, kể cả thử lại, mà một tác vụ có thể gửi trong cửa sổ.", + "workflowBudget.maxDistinctChildren": "Tác tử phụ khác nhau mỗi cửa sổ", + "workflowBudget.maxDistinctChildrenHint": "Số luồng tác tử phụ khác nhau mà một tác vụ có thể khởi chạy trong cửa sổ.", + "workflowBudget.range": "Mặc định {default}, cho phép {min}–{max}.", + "workflowBudget.invalid": "Nhập một số nguyên từ {min} đến {max}.", + "workflowBudget.increase": "Tăng {label}", + "workflowBudget.decrease": "Giảm {label}", + "workflowBudget.reset": "Khôi phục mặc định", + "workflowBudget.loadFailed": "Không thể tải giới hạn quy trình.", + "workflowBudget.saveFailed": "Không thể lưu giới hạn quy trình.", + "workflowBudget.saved": "Đã lưu giới hạn quy trình.", ...desktopCompatibilityCopy("vi"), "claude.tierDefaults.unset": "Chưa đặt (giữ cài đặt Claude hiện có)", "claude.tierDefaults.recovery": "Đã dừng áp dụng và khôi phục tự động thất bại. Kiểm tra bản sao lưu bên dưới trước khi thử lại.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 48b05bda9a5..63c1ac42d41 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -102,6 +102,22 @@ export const zhTW: Record = { "nativeReasoningRetention.loadFailed": "無法載入原生推理設定。", "nativeReasoningRetention.saveFailed": "無法儲存原生推理設定。", "nativeReasoningRetention.saved": "原生推理設定已儲存。", + "workflowBudget.title": "子代理工作流程限制", + "workflowBudget.description": "依任務(即一個對話及其啟動的子代理)設定的上限,在滾動的 {minutes} 分鐘視窗內計數。超過上限的請求會在聯絡任何供應商之前於本機以 429 拒絕。變更從下一個請求開始生效;已計入的用量會保留。", + "workflowBudget.maxConcurrentChildren": "平行輪次", + "workflowBudget.maxConcurrentChildrenHint": "一個任務可同時執行的輪次數。一律為你自己的對話保留一個名額,因此子代理少一個。", + "workflowBudget.maxPhysicalSends": "每個視窗的模型請求數", + "workflowBudget.maxPhysicalSendsHint": "一個任務在視窗內可向供應商傳送的請求數(含重試)。", + "workflowBudget.maxDistinctChildren": "每個視窗的不同子代理數", + "workflowBudget.maxDistinctChildrenHint": "一個任務在視窗內可啟動的不同子代理執行緒數。", + "workflowBudget.range": "預設 {default},允許 {min}–{max}。", + "workflowBudget.invalid": "請輸入 {min} 到 {max} 之間的整數。", + "workflowBudget.increase": "增加{label}", + "workflowBudget.decrease": "減少{label}", + "workflowBudget.reset": "還原預設值", + "workflowBudget.loadFailed": "無法載入工作流程限制。", + "workflowBudget.saveFailed": "無法儲存工作流程限制。", + "workflowBudget.saved": "工作流程限制已儲存。", ...desktopCompatibilityCopy("zh-TW"), "claude.tierDefaults.unset": "未設定(保留現有 Claude 設定)", "claude.tierDefaults.recovery": "套用已停止,自動還原失敗。重試前請檢查下方備份。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index d04ea73e9b5..005f5099ab1 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -104,6 +104,22 @@ export const zh: Record = { "nativeReasoningRetention.loadFailed": "无法加载原生推理设置。", "nativeReasoningRetention.saveFailed": "无法保存原生推理设置。", "nativeReasoningRetention.saved": "原生推理设置已保存。", + "workflowBudget.title": "子代理工作流限制", + "workflowBudget.description": "按任务(即一个对话及其启动的子代理)设置的上限,在滚动的 {minutes} 分钟窗口内计数。超过上限的请求会在联系任何提供商之前于本地以 429 拒绝。更改从下一个请求开始生效;已计入的用量会保留。", + "workflowBudget.maxConcurrentChildren": "并行轮次", + "workflowBudget.maxConcurrentChildrenHint": "一个任务可同时运行的轮次数。始终为你自己的对话保留一个名额,因此子代理少一个。", + "workflowBudget.maxPhysicalSends": "每个窗口的模型请求数", + "workflowBudget.maxPhysicalSendsHint": "一个任务在窗口内可向提供商发送的请求数(含重试)。", + "workflowBudget.maxDistinctChildren": "每个窗口的不同子代理数", + "workflowBudget.maxDistinctChildrenHint": "一个任务在窗口内可启动的不同子代理线程数。", + "workflowBudget.range": "默认 {default},允许 {min}–{max}。", + "workflowBudget.invalid": "请输入 {min} 到 {max} 之间的整数。", + "workflowBudget.increase": "增加{label}", + "workflowBudget.decrease": "减少{label}", + "workflowBudget.reset": "恢复默认值", + "workflowBudget.loadFailed": "无法加载工作流限制。", + "workflowBudget.saveFailed": "无法保存工作流限制。", + "workflowBudget.saved": "工作流限制已保存。", ...desktopCompatibilityCopy("zh"), "claude.tierDefaults.unset": "未设置(保留现有 Claude 设置)", "claude.tierDefaults.recovery": "应用已停止,自动恢复失败。重试前请检查下方备份。", diff --git a/gui/src/pages/Subagents.tsx b/gui/src/pages/Subagents.tsx index b56e6199702..d21200620ae 100644 --- a/gui/src/pages/Subagents.tsx +++ b/gui/src/pages/Subagents.tsx @@ -1,4 +1,5 @@ import SubagentForceControl from "../components/subagents-workspace/SubagentForceControl"; +import WorkflowBudgetPanel from "../components/WorkflowBudgetPanel"; import { useCallback, useEffect, useRef, useState } from "react"; import { readJsonOrThrow } from "../fetch-json"; import { Notice } from "../ui"; @@ -365,6 +366,7 @@ export default function Subagents({ apiBase }: { apiBase: string }) { )} + ; +let win: Window; +let root: Root | undefined; +let container: HTMLDivElement; +let setting: Record; +let failLoad: boolean; +let failSave: boolean; +let writes: unknown[]; +let urls: string[]; + +const payload = () => ({ ...setting, interactiveReserve: 1, windowMs: 600_000, defaults, bounds }); + +beforeEach(() => { + previous = Object.fromEntries(globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])); + win = new Window({ url: "http://localhost/" }); + for (const key of ["document", "window", "navigator", "localStorage", "sessionStorage", "HTMLElement"] as const) { + Object.defineProperty(globalThis, key, { configurable: true, value: key === "window" ? win : win[key] }); + } + Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true }); + win.localStorage.setItem("ocx-lang", "en"); + setting = { ...defaults }; + failLoad = false; failSave = false; writes = []; urls = []; + Object.defineProperty(globalThis, "fetch", { configurable: true, writable: true, value: async (input: unknown, init?: RequestInit) => { + urls.push(String(input)); + if (init?.method === "PUT") { + const body = JSON.parse(String(init.body)) as Record | null; + writes.push(body); + if (failSave) return Response.json({ error: "fixture failure" }, { status: 500 }); + setting = body === null ? { ...defaults } : { ...setting, ...body }; + } else if (failLoad) return Response.json({ error: "unavailable" }, { status: 503 }); + return Response.json(payload()); + } }); +}); + +afterEach(async () => { + if (root) await act(async () => { root!.unmount(); }); + root = undefined; win.close(); + for (const key of globals) { + if (previous[key]) Object.defineProperty(globalThis, key, previous[key]!); + else delete (globalThis as Record)[key]; + } +}); + +async function flush() { await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); }); } +async function render(base = "") { + container = win.document.createElement("div") as unknown as HTMLDivElement; + win.document.body.appendChild(container); + root = (await import("react-dom/client")).createRoot(container); + await act(async () => { root!.render(); }); + await flush(); +} +function button(label: string) { + return [...container.querySelectorAll("button")] + .find(value => value.textContent === label || value.getAttribute("aria-label") === label)!; +} +const input = (label: string) => container.querySelector('input[aria-label="' + label + '"]')!; +async function click(label: string) { await act(async () => { button(label).click(); }); } +async function enter(label: string, value: string) { + await act(async () => { + Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, "value")!.set!.call(input(label), value); + input(label).dispatchEvent(new win.Event("input", { bubbles: true })); + }); +} + +test("shows the effective ceilings and saves all three through the dedicated route", async () => { + await render("/proxy"); + expect(input(PARALLEL).value).toBe("8"); + expect(input(SENDS).value).toBe("256"); + expect(container.textContent).toContain("rolling 10-minute window"); + expect(container.textContent).toContain("Default 8, allowed 2–64."); + expect(button("Save").disabled).toBe(true); + expect(button("Restore defaults").disabled).toBe(true); + await click("Increase " + PARALLEL); await click("Increase " + PARALLEL); + await enter(SENDS, "1024"); + await click("Save"); + expect(writes).toEqual([{ maxConcurrentChildren: 10, maxPhysicalSends: 1_024, maxDistinctChildren: 64 }]); + expect(input(PARALLEL).value).toBe("10"); + expect(button("Save").disabled).toBe(true); + expect(container.querySelector('[role="status"]')?.textContent).toBe("Workflow limits saved."); + expect(urls.every(value => value === "/proxy/api/workflow-budget/settings")).toBe(true); +}); + +test("saving the defaults and restoring both write null so the install keeps following built-in ceilings", async () => { + setting = { ...defaults, maxConcurrentChildren: 12 }; + await render(); + expect(input(PARALLEL).value).toBe("12"); + await enter(PARALLEL, "8"); + await click("Save"); + expect(writes).toEqual([null]); + expect(button("Restore defaults").disabled).toBe(true); + await click("Increase " + PARALLEL); await click("Save"); + expect(writes.at(-1)).toEqual({ ...defaults, maxConcurrentChildren: 9 }); + await click("Restore defaults"); + expect(writes.at(-1)).toBeNull(); + expect(input(PARALLEL).value).toBe("8"); + expect(button("Restore defaults").disabled).toBe(true); +}); + +test("an out-of-range draft names the allowed range and blocks saving", async () => { + await render(); + await enter(PARALLEL, "1"); + expect(container.querySelector('[role="alert"]')?.textContent).toBe("Enter a whole number from 2 to 64."); + expect(input(PARALLEL).getAttribute("aria-invalid")).toBe("true"); + expect(button("Save").disabled).toBe(true); + await enter(PARALLEL, "65"); + expect(button("Save").disabled).toBe(true); + await click("Decrease " + PARALLEL); + expect(input(PARALLEL).value).toBe("64"); + expect(container.querySelector('[role="alert"]')).toBeNull(); + expect(button("Save").disabled).toBe(false); + expect(writes).toEqual([]); +}); + +test("load errors disable every control and retry reloads", async () => { + failLoad = true; await render(); + expect(button("Save").disabled).toBe(true); + expect(button("Restore defaults").disabled).toBe(true); + expect(button("Increase " + PARALLEL).disabled).toBe(true); + expect(container.querySelector('[role="alert"]')?.textContent).toContain("Could not load workflow limits."); + failLoad = false; setting = { ...defaults, maxPhysicalSends: 512 }; + await click("Retry"); + expect(input(SENDS).value).toBe("512"); + expect(button("Increase " + PARALLEL).disabled).toBe(false); +}); + +test("a failed save keeps the draft and does not claim success", async () => { + await render(); + await click("Increase " + PARALLEL); + failSave = true; await click("Save"); + expect(input(PARALLEL).value).toBe("9"); + expect(button("Save").disabled).toBe(false); + expect(container.querySelector('[role="alert"]')?.textContent).toContain("Could not save workflow limits."); + expect(container.querySelector('[role="status"]')).toBeNull(); +}); diff --git a/scripts/ci/bun-install-frozen.sh b/scripts/ci/bun-install-frozen.sh index 0cc559d30da..e6d7bc3aec0 100755 --- a/scripts/ci/bun-install-frozen.sh +++ b/scripts/ci/bun-install-frozen.sh @@ -1,15 +1,71 @@ #!/usr/bin/env bash -# Frozen-lockfile install with a per-attempt deadline and one total budget. -# -# A registry or runner stall can leave `bun install` printing only its banner until the job's -# timeout cancels the run, which reads as a failed check unrelated to the change under test. -# Each attempt therefore runs under a process deadline, and only an attempt that the deadline -# killed is retried. Any other failure, such as a lockfile that no longer matches, fails at once. -# All directories share one total budget, so retries end well inside the shortest job timeout -# that calls this (5 minutes) and leave time for setup, the GUI build and the tests. -# -# Usage: bash scripts/ci/bun-install-frozen.sh [dir ...] (default: the current directory) -set -euo pipefail +# Frozen installs: one witnessed deadline, a shared budget, and no PID-directed cleanup. +# GNU timeout/gtimeout bounds a fixed reader, never the install's own exit status. +# Bootstrap FIFO names disappear before install code runs; this is not a sandbox against +# arbitrary same-user process inspection. Cleanup covers descendants that remain in the group. +set -eu + +own_group() { + local stat state ppid group rest + if [[ -r /proc/$$/stat ]]; then + IFS= read -r stat < /proc/$$/stat || return 1 + read -r state ppid group rest <<< "${stat##*) }" + else + read -r group <<< "$(ps -o pgid= -p "$$")" || return 1 + fi + [[ "$group" == "$$" ]] +} + +keeper() { + local boot="$1" timer="$2" grace="$3" dir="$4" deadline token completion rc + shift 4 + set +m + own_group || exit 70 # No signal 0 until this process is its group leader. + trap ':' TERM + # O_RDWR bootstrap avoids a blocked open if the other endpoint dies during setup. + exec 6<>"$boot/outcome" 8>"$boot/outcome" + exec 6>&- + exec 6<>"$boot/lifeline" 9<"$boot/lifeline" + exec 6>&- + printf 'ready %s\n' "$$" >&8 + IFS=' ' read -r -t 5 token deadline <&9 || exit 70 + [[ "$token" == go && "$deadline" =~ ^[1-9][0-9]*$ ]] || exit 70 + + # Only the parent owns a lifeline writer. Its death/cancellation is EOF, never a PID kill. + ( exec 8>&-; trap '' TERM; cat <&9 >/dev/null; kill -TERM 0; sleep 0.2; kill -KILL 0 ) >/dev/null 2>&1 & + exec 9<&- # Only the EOF helper needs the read endpoint now. + if ! cd "$dir"; then printf 'fail 66\n' >&8; kill -KILL 0; fi + exec 7>&1 + report_install() { + local status=0 + exec 8>&- + # The command gets neither a control descriptor nor the result pipe's writer. + "$@" >&7 7>&- || status=$? + printf '%s\n' "$status" + } + rc=0 + # The fixed reader exits only 0/70; install 124/137 are pipe data, not timer statuses. + # Process substitution does not wait for a stalled reporter when the reader times out. + completion=$("$timer" --foreground "${deadline}s" bash -c ' + IFS= read -r status || exit 70 + [[ "$status" =~ ^(0|[1-9][0-9]{0,2})$ && $status -le 255 ]] || exit 70 + printf "%s" "$status" + ' < <(report_install "$@") 7>&- 8>&-) || rc=$? + if [[ $rc -eq 0 ]]; then + printf 'result %s\n' "$completion" >&8 + elif [[ $rc -eq 124 ]]; then + printf 'deadline 124\n' >&8 + kill -TERM 0 + sleep "$grace" + else + printf 'fail 70\n' >&8 + fi + # This live process pins the group until its last action, including ordinary success. + kill -KILL 0 + exit 70 +} + +if [[ "${1:-}" == --ocx-install-keeper ]]; then shift; keeper "$@"; exit 70; fi readonly ATTEMPT_SECONDS="${BUN_INSTALL_ATTEMPT_SECONDS:-45}" readonly TOTAL_SECONDS="${BUN_INSTALL_TOTAL_SECONDS:-150}" @@ -17,42 +73,112 @@ readonly MIN_ATTEMPT_SECONDS="${BUN_INSTALL_MIN_ATTEMPT_SECONDS:-5}" readonly KILL_GRACE_SECONDS="${BUN_INSTALL_KILL_GRACE_SECONDS:-5}" readonly MAX_ATTEMPTS="${BUN_INSTALL_MAX_ATTEMPTS:-3}" readonly BUN_INSTALL_BIN="${BUN_INSTALL_BIN:-bun}" +readonly SCRIPT_PATH="${BASH_SOURCE[0]}" +for value in "$ATTEMPT_SECONDS" "$TOTAL_SECONDS" "$MIN_ATTEMPT_SECONDS" "$KILL_GRACE_SECONDS" "$MAX_ATTEMPTS"; do + [[ "$value" =~ ^[1-9][0-9]*$ && ${#value} -le 6 ]] || { echo '::error::Invalid install deadline configuration.'; exit 70; } +done +TIMEOUT_BIN="" +for candidate in timeout gtimeout; do + if command -v "$candidate" >/dev/null 2>&1 && "$candidate" --foreground 1s true >/dev/null 2>&1; then + TIMEOUT_BIN=$(command -v "$candidate"); break + fi +done +[[ -n "$TIMEOUT_BIN" ]] || { echo '::error::GNU timeout is required to bound bun install.' >&2; exit 69; } -if ! command -v timeout >/dev/null 2>&1 || ! timeout --signal=TERM --kill-after=1s 1s true >/dev/null 2>&1; then - echo "::error::GNU timeout is required to bound bun install." >&2 - exit 69 -fi +ATTEMPT_VERDICT=fail +ATTEMPT_STATUS=70 +run_attempt() { + local dir="$1" deadline="$2" boot keeper_pid keeper_status=70 tag="" value="" extra="" + local cancel_status=0 interrupted=no drained=no remaining started=no monitor=no + shift 2 + boot=$(mktemp -d "${TMPDIR:-/tmp}/ocx-bun-install.XXXXXX") || return 70 + mkfifo "$boot/outcome" "$boot/lifeline" || return 70 + exec 8<>"$boot/outcome" 9<>"$boot/lifeline" + trap 'cancel_status=143; interrupted=yes; exec 9>&-' TERM + trap 'cancel_status=130; interrupted=yes; exec 9>&-' INT + trap 'cancel_status=129; interrupted=yes; exec 9>&-' HUP + [[ $- == *m* ]] && monitor=yes + set -m + bash "$SCRIPT_PATH" --ocx-install-keeper "$boot" "$TIMEOUT_BIN" "$KILL_GRACE_SECONDS" "$dir" "$@" 8>&- 9>&- & + keeper_pid=$! + [[ "$monitor" == yes ]] || set +m + ATTEMPT_VERDICT=fail; ATTEMPT_STATUS=70 + if IFS=' ' read -r -t 5 tag value extra <&8 && [[ "$tag" == ready && "$value" == "$keeper_pid" && -z "$extra" ]]; then + # After the handshake, unlink every IPC name before permitting any install code. + if rm "$boot/outcome" "$boot/lifeline" 8>&- 9>&- && rmdir "$boot" 8>&- 9>&-; then + remaining=$(( TOTAL_SECONDS - SECONDS - KILL_GRACE_SECONDS - 2 )) + (( remaining < deadline )) && deadline=$remaining + if (( deadline >= MIN_ATTEMPT_SECONDS && cancel_status == 0 )); then + if printf 'go %s\n' "$deadline" >&9; then + started=yes + tag=""; value=""; extra="" + if IFS=' ' read -r -t "$(( deadline + KILL_GRACE_SECONDS ))" tag value extra <&8 && [[ -z "$extra" ]]; then + case "$tag:$value" in + result:*) + if [[ "$value" =~ ^(0|[1-9][0-9]{0,2})$ && $value -le 255 ]]; then + ATTEMPT_STATUS=$value + [[ $value -eq 0 ]] && ATTEMPT_VERDICT=ok + fi ;; + deadline:124) ATTEMPT_VERDICT=deadline; ATTEMPT_STATUS=124 ;; + fail:66) ATTEMPT_STATUS=66 ;; + esac + fi + fi + else + ATTEMPT_VERDICT=budget; ATTEMPT_STATUS=124 + fi + fi + fi + # A valid outcome keeps the lifeline open through the keeper's own complete grace cycle. + if [[ "$ATTEMPT_VERDICT" == fail && $ATTEMPT_STATUS -eq 70 ]] || [[ "$started" == no ]]; then exec 9>&-; fi + while :; do + interrupted=no + keeper_status=0; wait "$keeper_pid" 2>/dev/null || keeper_status=$? + [[ "$interrupted" == yes ]] || break + done + # EOF is not a drain proof. Reuse can cause a conservative failure, never a foreign kill. + for value in 1 2 3 4 5 6 7 8 9 10; do + if ! kill -0 -- "-$keeper_pid" 2>/dev/null; then drained=yes; break; fi + sleep 0.1 8>&- 9>&- + done + exec 9>&- 8>&- + trap - TERM INT HUP + if [[ -d "$boot" ]]; then + for value in outcome lifeline; do [[ ! -e "$boot/$value" ]] || rm "$boot/$value"; done + rmdir "$boot" + fi + if (( cancel_status > 0 )); then + ATTEMPT_VERDICT=cancel; ATTEMPT_STATUS=$cancel_status + elif [[ "$drained" != yes ]]; then + ATTEMPT_VERDICT=drain_fail; ATTEMPT_STATUS=70 + elif [[ "$started" == yes && $keeper_status -ne 137 ]]; then + ATTEMPT_VERDICT=fail; ATTEMPT_STATUS=70 + fi +} install_dir() { - local dir="$1" attempt status remaining deadline - # Never empty: macOS ships bash 3.2, where expanding an empty array under `set -u` aborts - # with "unbound variable". + local dir="$1" attempt deadline remaining + # Keep this array nonempty for Bash 3.2 with nounset. local -a install_cmd=("$BUN_INSTALL_BIN" install --frozen-lockfile) for ((attempt = 1; attempt <= MAX_ATTEMPTS; attempt++)); do - # The kill grace counts against the budget too, so the worst case stays at TOTAL_SECONDS. - remaining=$(( TOTAL_SECONDS - SECONDS - KILL_GRACE_SECONDS )) + remaining=$(( TOTAL_SECONDS - SECONDS - KILL_GRACE_SECONDS - 2 )) if (( remaining < MIN_ATTEMPT_SECONDS )); then echo "::error::bun install --frozen-lockfile in ${dir} has no time left in its ${TOTAL_SECONDS}s budget." return 124 fi deadline=$(( remaining < ATTEMPT_SECONDS ? remaining : ATTEMPT_SECONDS )) - status=0 - (cd "$dir" && timeout --signal=TERM --kill-after="${KILL_GRACE_SECONDS}s" "${deadline}s" \ - "${install_cmd[@]}") || status=$? - if [[ $status -eq 0 ]]; then return 0; fi - # 124: the deadline fired and the install exited on TERM; 137: it needed the KILL. - if [[ $status -ne 124 && $status -ne 137 ]]; then - echo "::error::bun install --frozen-lockfile failed in ${dir} (exit ${status}); not retrying." - return "$status" - fi - echo "::warning::bun install --frozen-lockfile in ${dir} exceeded ${deadline}s (attempt ${attempt}/${MAX_ATTEMPTS})." - # A killed install may have extracted bun but not finished its trusted postinstall. - # Plain install can then report success while leaving bin/bun.exe a placeholder. + run_attempt "$dir" "$deadline" "${install_cmd[@]}" + case "$ATTEMPT_VERDICT" in + ok) return 0 ;; + cancel) return "$ATTEMPT_STATUS" ;; + budget) echo "::error::bun install --frozen-lockfile in ${dir} has no time left in its ${TOTAL_SECONDS}s budget."; return 124 ;; + deadline) echo "::warning::bun install --frozen-lockfile in ${dir} exceeded ${deadline}s (attempt ${attempt}/${MAX_ATTEMPTS})." ;; + *) echo "::error::bun install --frozen-lockfile failed in ${dir} (exit ${ATTEMPT_STATUS}, ${ATTEMPT_VERDICT}); not retrying."; return "$ATTEMPT_STATUS" ;; + esac install_cmd=("$BUN_INSTALL_BIN" install --frozen-lockfile --force) done echo "::error::bun install --frozen-lockfile in ${dir} exceeded its deadline ${MAX_ATTEMPTS} times." return 124 } - if [[ $# -eq 0 ]]; then set -- .; fi for dir in "$@"; do install_dir "$dir"; done diff --git a/scripts/test-layout/layout-clients.json b/scripts/test-layout/layout-clients.json index fa50b9af986..01a3abb4020 100644 --- a/scripts/test-layout/layout-clients.json +++ b/scripts/test-layout/layout-clients.json @@ -32,6 +32,7 @@ "droid-normalized-reasoning-defaults.test.ts": "clients", "desktop-app-server-shim.test.ts": "clients", "desktop-app-server-shim-launcher.test.ts": "clients", + "desktop-app-server-exec-boundary.test.ts": "clients", "desktop-bundle-trust-async.test.ts": "clients", "desktop-chatgpt-config.test.ts": "clients", "desktop-supervised-restart.test.ts": "clients", diff --git a/scripts/test-layout/layout-codex-collision.json b/scripts/test-layout/layout-codex-collision.json index d93e0f3748a..05cd39368be 100644 --- a/scripts/test-layout/layout-codex-collision.json +++ b/scripts/test-layout/layout-codex-collision.json @@ -1,4 +1,8 @@ { + "codex-role-configured-credentials.test.ts": "server", + "codex-role-capability-consent.test.ts": "routing", + "codex-role-capability-redirects.test.ts": "routing", + "codex-role-measured-ties.test.ts": "routing", "messaging-remote-port-reservation.test.ts": "codex-integration", "messaging-remote-forward-failure.test.ts": "codex-integration", "codex-cli-windows-child.test.ts": "codex-integration", diff --git a/scripts/test-layout/layout-exec-boundary.json b/scripts/test-layout/layout-exec-boundary.json index e64ba5579f4..7ca28c34045 100644 --- a/scripts/test-layout/layout-exec-boundary.json +++ b/scripts/test-layout/layout-exec-boundary.json @@ -1,5 +1,9 @@ { "exec-trust-boundary.test.ts": "codex-integration", "exec-boundary-launch.test.ts": "codex-integration", + "exec-launcher-boundary.test.ts": "codex-integration", + "exec-cli-renamed-app-bundle.test.ts": "codex-integration", + "exec-cli-bundle-containment.test.ts": "codex-integration", + "exec-renamed-app-bundle.test.ts": "codex-integration", "exec-known-app-bundles.test.ts": "codex-integration" } diff --git a/scripts/test-layout/layout-providers.json b/scripts/test-layout/layout-providers.json index e79f4b53fc7..cadb953254c 100644 --- a/scripts/test-layout/layout-providers.json +++ b/scripts/test-layout/layout-providers.json @@ -1,4 +1,7 @@ { + "xai-additional-tools.test.ts": "providers/xai", + "xai-replay-provenance.test.ts": "providers/xai", + "xai-loaded-tool-provenance.test.ts": "providers/xai", "free-directory-runcrate.test.ts": "providers", "cc-switch-import.test.ts": "providers", "kiro-completion-with-real-tools.test.ts": "providers/kiro" diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 551e1fa899a..2442a618c9c 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -2,7 +2,7 @@ "version": 1, "root": "tests", "explicitShards": ["layout-clients.json", "layout-codex-collision.json", "layout-agent-assertion.json", "layout-chat.json", "layout-replay.json", "layout-adapters.json", "layout-jev.json", "layout-workbuddy.json", "layout-combos.json", "layout-credit-renewal.json", "layout-server-inbound.json", "layout-providers.json", "layout-responses.json", "layout-exec-boundary.json", "layout-persistent-dashboard.json", "layout-start-ownership.json", "layout-config.json", "layout-kiro-usage.json", "layout-usage.json"], - "explicit": { "service-desktop-recovery.test.ts": "service", "catalog-omit-disabled-native.test.ts": "codex-integration", "provider-input-modalities.test.ts": "providers", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "codex-provider-routing.test.ts": "codex-integration", "config-native-reasoning-retention.test.ts": "config", "server-native-reasoning-retention-merge.test.ts": "server", "server-native-reasoning-retention.test.ts": "server", "responses-retained-reasoning-stream-recovery.test.ts": "responses", "openai-native-hosted-search-history.test.ts": "adapters/openai", "responses-native-compact-search-history.test.ts": "responses", "config-hosted-search-projection.test.ts": "config", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "cli-provider-list-active.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "bridge-terminal-refusal.test.ts": "adapters", "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", "combo-terminal-refusal.test.ts": "routing", "server-terminal-refusal-wiring.test.ts": "server", "incomplete-reason-normalization.test.ts": "responses", "advisor-retention.test.ts": "advisor", "responses-native-key-cooldown.test.ts": "responses", "responses-run-turn-key-readmission.test.ts": "responses", "qoder-spawn-readmission.test.ts": "adapters", "api-key-selection-cooldown.test.ts": "providers", "api-key-auto-pause-distribution.test.ts": "providers", "explicit-key-pause-oauth-boundary.test.ts": "providers", "server-chat-native-key-selection.test.ts": "server", "compact-key-selection.test.ts": "responses", "messages-native-key-selection.test.ts": "claude-integration", "coding-agent-windows-dispatch.test.ts": "adapters", "coding-agent-native-empty-argv.test.ts": "windows", + "explicit": { "service-desktop-recovery.test.ts": "service", "catalog-omit-disabled-native.test.ts": "codex-integration", "provider-input-modalities.test.ts": "providers", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "codex-provider-routing.test.ts": "codex-integration", "config-native-reasoning-retention.test.ts": "config", "server-native-reasoning-retention-merge.test.ts": "server", "server-native-reasoning-retention.test.ts": "server", "config-workflow-budget.test.ts": "config", "server-workflow-budget-settings.test.ts": "server", "responses-retained-reasoning-stream-recovery.test.ts": "responses", "openai-native-hosted-search-history.test.ts": "adapters/openai", "responses-native-compact-search-history.test.ts": "responses", "config-hosted-search-projection.test.ts": "config", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "cli-provider-list-active.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "bridge-terminal-refusal.test.ts": "adapters", "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", "combo-terminal-refusal.test.ts": "routing", "server-terminal-refusal-wiring.test.ts": "server", "incomplete-reason-normalization.test.ts": "responses", "advisor-retention.test.ts": "advisor", "responses-native-key-cooldown.test.ts": "responses", "responses-run-turn-key-readmission.test.ts": "responses", "qoder-spawn-readmission.test.ts": "adapters", "api-key-selection-cooldown.test.ts": "providers", "api-key-auto-pause-distribution.test.ts": "providers", "explicit-key-pause-oauth-boundary.test.ts": "providers", "server-chat-native-key-selection.test.ts": "server", "compact-key-selection.test.ts": "responses", "messages-native-key-selection.test.ts": "claude-integration", "coding-agent-windows-dispatch.test.ts": "adapters", "coding-agent-native-empty-argv.test.ts": "windows", "stop-restore-history-ownership.test.ts": "service", "cli-stop-refusal-transport.test.ts": "cli", "service-proxy-env.test.ts": "service", "openai-chat-standalone-bare-block.test.ts": "adapters/openai", @@ -604,7 +604,7 @@ "codex-cli-installation-targets.test.ts": "codex-integration", "codex-cli-update-launcher-policy.test.ts": "codex-integration", "codex-cli-update-integrity.test.ts": "codex-integration", - "codex-cli-update-plan.test.ts": "codex-integration", + "codex-cli-update-plan.test.ts": "codex-integration", "codex-cli-update-parent-async.test.ts": "codex-integration", "codex-cli-update-zero-effect.test.ts": "codex-integration", "codex-cli-windows-installation-files.test.ts": "codex-integration", "codex-forward-access-programs-writer.test.ts": "codex-integration", @@ -705,7 +705,7 @@ "codex-restart-route.test.ts": "codex-integration", "codex-restore-app-rewrite.test.ts": "codex-integration", "codex-retained-root-serialization.test.ts": "codex-integration", - "codex-routing-cache-affinity-detour.test.ts": "codex-integration", + "codex-routing-cache-affinity-detour.test.ts": "codex-integration", "codex-routing-elapsed-short-reset.test.ts": "codex-integration", "codex-routing.test.ts": "codex-integration", "codex-runtime.test.ts": "codex-integration", "codex-runtime-macos-desktop.test.ts": "codex-integration", "codex-clamp-notice.test.ts": "codex-integration", "codex-runtime-wsl-desktop.test.ts": "codex-integration", @@ -1495,7 +1495,7 @@ "repo-hygiene.test.ts": "ci-workflows", "request-decompress.test.ts": "usage", "request-evidence.test.ts": "usage", - "request-history-index.test.ts": "usage", + "request-history-index.test.ts": "usage", "request-history-integrity.test.ts": "usage", "request-log-attempt-identity.test.ts": "usage", "request-log-conversation.test.ts": "usage", "request-log-estimate-cap.test.ts": "usage", @@ -1594,6 +1594,7 @@ "responses-show-thinking-summary.test.ts": "responses", "responses-snapshot-repair-server.test.ts": "responses", "responses-snapshot-repair.test.ts": "responses", + "responses-snapshot-scaling.test.ts": "responses", "responses-sparse-terminal-tool-scope.test.ts": "responses", "responses-spend-ledger-wiring.test.ts": "responses", "responses-spend-contract-c.test.ts": "responses", diff --git a/src/adapters/kiro/adapter.ts b/src/adapters/kiro/adapter.ts index 11285f97573..b4d51979dd2 100644 --- a/src/adapters/kiro/adapter.ts +++ b/src/adapters/kiro/adapter.ts @@ -297,7 +297,10 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter toolNameMap = built.nameMap; conversationId = built.conversationId; completionMode = built.completionMode; - requestSnapshot = structuredClone(parsed); + // Runtime callbacks belong to the original request, not the retry payload. + requestSnapshot = structuredClone(Object.fromEntries( + Object.entries(parsed).filter(([, value]) => typeof value !== "function"), + )) as OcxParsedRequest; firstRequestBodyBytes = Buffer.byteLength(built.request.body); requestAbortSignal = incoming?.abortSignal; return built.request; diff --git a/src/adapters/openai-chat/messages.ts b/src/adapters/openai-chat/messages.ts index 7b45c4581cf..43963d54eb4 100644 --- a/src/adapters/openai-chat/messages.ts +++ b/src/adapters/openai-chat/messages.ts @@ -356,10 +356,10 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv type: "function", function: { name: namespacedToolName(tc.namespace, tc.name), arguments: JSON.stringify(tc.arguments) }, })); - if (!chatMsg.content) chatMsg.content = emptyAssistantContent(provider); + if (!chatMsg.content) chatMsg.content = emptyAssistantContent(provider, parsed.modelId); } if (hasReplayedReasoning && chatMsg.content === undefined && chatMsg.tool_calls === undefined) { - chatMsg.content = emptyAssistantContent(provider); + chatMsg.content = emptyAssistantContent(provider, parsed.modelId); } out.push(chatMsg); pendingToolCalls = wireToolCalls.map(({ tc, id }) => ({ id, name: namespacedToolName(tc.namespace, tc.name) })); @@ -413,7 +413,7 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv : { reasoning_content: orphanReasoning }; out.push({ role: "assistant", - content: emptyAssistantContent(provider), + content: emptyAssistantContent(provider, parsed.modelId), ...orphanReasoningFields, tool_calls: [{ id: toolCallId, @@ -448,8 +448,11 @@ export function safeToolName(name: string | undefined): string { return sanitized; } -export function emptyAssistantContent(provider: OcxProviderConfig): string | { type: "text"; text: string }[] { - return isVolcengineArkPaygChatTarget(provider) ? [{ type: "text", text: "" }] : ""; +export function emptyAssistantContent(provider: OcxProviderConfig, modelId = ""): string | { type: "text"; text: string }[] { + // Kimi K3.1 Pro rejects Ark's legacy empty-text array during tool continuations. + const upstreamModelId = provider.modelSuffixBracketStrip ? stripBracketedModelSuffix(modelId) : modelId; + const kimi31Pro = /^kimi-k3-1-pro(?:-\d{6})?$/.test(upstreamModelId); + return isVolcengineArkPaygChatTarget(provider) && !kimi31Pro ? [{ type: "text", text: "" }] : ""; } /** Reconstruct completed text-only thinking; never invent signatures or placeholder reasoning. */ diff --git a/src/adapters/openai-responses/muse-tool-catalog.ts b/src/adapters/openai-responses/muse-tool-catalog.ts new file mode 100644 index 00000000000..e0c92d70205 --- /dev/null +++ b/src/adapters/openai-responses/muse-tool-catalog.ts @@ -0,0 +1,87 @@ +import { isPlainObject } from "./internal"; +import { promoteClientLoadedTools } from "./tool-schema"; +import { rewriteRoutedCustomToolsForUpstream } from "../../responses/custom-tool-compat"; +import { hasItemProvenance, isHistoricalItem } from "../../responses/item-provenance"; + +export class MuseToolCatalogCompatibilityError extends Error { + constructor() { + super("Malformed tool catalog for Meta Responses."); + this.name = "MuseToolCatalogCompatibilityError"; + } +} + +function isRepresentableName(name: unknown): name is string { + return typeof name === "string" && name.length > 0 + && [...name].every(character => character.charCodeAt(0) > 0x1f && character.charCodeAt(0) !== 0x7f); +} + +function normalizeCatalog(tools: unknown, inNamespace = false): Record[] { + if (!Array.isArray(tools)) throw new MuseToolCatalogCompatibilityError(); + return tools.flatMap(tool => { + if (!isPlainObject(tool) || typeof tool.type !== "string") throw new MuseToolCatalogCompatibilityError(); + if (inNamespace && tool.type !== "function" && tool.type !== "custom") throw new MuseToolCatalogCompatibilityError(); + if (tool.type === "function" && isPlainObject(tool.function)) { + const { function: definition, ...outer } = tool; + tool = { ...outer, ...definition, type: "function" }; + } + if (["namespace", "function", "custom"].includes(tool.type) + && !isRepresentableName(tool.name)) throw new MuseToolCatalogCompatibilityError(); + if (tool.type !== "namespace") return [tool]; + return [{ ...tool, tools: normalizeCatalog(tool.tools, true) }]; + }); +} + +function flattenBuiltinNamespace(tools: Record[]): Record[] { + const bareNames = new Set(tools.filter(tool => tool.type !== "namespace").map(tool => tool.name)); + return tools.flatMap(tool => { + if (tool.type !== "namespace" || tool.name !== "functions") return [tool]; + // Match namespace lowering: an explicit bare declaration owns this identity, + // even when it occurs later or in a different current declaration carrier. + return (tool.tools as Record[]).filter(child => !bareNames.has(child.name)) + .map(child => tool.defer_loading === undefined ? child : { defer_loading: tool.defer_loading, ...child }); + }); +} + +/** + * Meta rejects Codex's additional_tools input items. Capture the current catalog + * before history rewrites can shift the replay boundary or consume duplicate names. + * Historical messages and search results remain replay context, never declarations. + * The caller gates this projection on the actual Meta destination. + */ +export function normalizeMuseToolCatalog(body: unknown, replayPrefixLength = 0, supportsCustomTools?: boolean): unknown { + if (!isPlainObject(body)) return body; + // Namespace lowering drops malformed groups; reject them before any partial promotion. + const topLevelTools = body.tools === undefined ? undefined : normalizeCatalog(body.tools); + if (!Array.isArray(body.input)) return topLevelTools === undefined ? body : { ...body, tools: flattenBuiltinNamespace(topLevelTools) }; + const start = Number.isFinite(replayPrefixLength) + ? Math.min(body.input.length, Math.max(0, Math.trunc(replayPrefixLength))) : 0; + // Item provenance owns membership once present: an earlier rewrite (Zed lifting a replayed + // wrapper) may already have shortened the input below the numeric prefix. + const originalInput: unknown[] = body.input; + const tagged = originalInput.some(hasItemProvenance); + const isCurrent = (item: unknown, index: number) => tagged ? !isHistoricalItem(item) : index >= start; + const normalizedInput = body.input.map((item, index) => { + if (!isCurrent(item, index)) return item; + if (isPlainObject(item) && (item.type === "additional_tools" + || (item.type === "tool_search_output" && item.tools !== undefined))) { + const tools = normalizeCatalog(item.tools); + return { ...item, tools: item.type === "tool_search_output" ? flattenBuiltinNamespace(tools) : tools }; + } + return item; + }); + const currentInput = normalizedInput.filter((_item, index) => isCurrent(originalInput[index], index)); + const declared = [...(topLevelTools ?? [])]; + for (const item of currentInput) { + if (isPlainObject(item) && item.type === "additional_tools" && Array.isArray(item.tools)) declared.push(...item.tools); + } + // All current declarations precede discoveries, regardless of their carrier. + const current = promoteClientLoadedTools({ ...body, tools: flattenBuiltinNamespace(declared), input: currentInput }); + const tools = isPlainObject(current) && Array.isArray(current.tools) ? current.tools : declared; + // Replay still needs the old declarations to lower custom calls and their outputs. + // Retain only that input projection, never the historical declaration/restore sets. + const replay = rewriteRoutedCustomToolsForUpstream({ ...body, input: normalizedInput }, supportsCustomTools).body; + const replayInput = isPlainObject(replay) && Array.isArray(replay.input) ? replay.input : normalizedInput; + const input = replayInput.filter(item => !isPlainObject(item) || item.type !== "additional_tools"); + const hasCatalog = topLevelTools !== undefined || tools.length > 0 || input.length !== body.input.length; + return { ...body, input, ...(hasCatalog ? { tools } : {}) }; +} diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index 256707dc813..6f6ab2c07e2 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -3,7 +3,7 @@ import { normalizeRoutedAgentMessages } from "../routed-agent-messages"; import { frameCompactionHistory } from "../../responses/compaction-history-framing"; import { nameRoutedIdentity, repairIdentityInResponsesBody, stripRoutedIdentity } from "../identity"; import { stripBracketedModelSuffix } from "../openai-chat"; -import { normalizeOpenCodeGoAdditionalTools } from "../opencode-go-additional-tools"; +import { isOpenCodeGoResponsesUrl, isXaiResponsesUrl, promoteResponsesAdditionalTools } from "../opencode-go-additional-tools"; import { isXaiResponsesDestination } from "../../providers/xai-transport"; import { applyGithubCopilotContextTier } from "../../providers/github-copilot-context"; import { Buffer } from "node:buffer"; @@ -40,7 +40,7 @@ import { emittedFastWire, } from "../../providers/fastwire"; import { dropResponsesReasoningInputItems, mapRoutedResponsesReasoningEffort, normalizeConfiguredReasoningSummaryDelivery, sanitizeReasoningInputContent, stripDisabledReasoningSummaries, stripDisabledVerbosity, stripInternalReasoningEnabled, stripNoneReasoningSummary, stripUnsupportedReasoningSummaryDelivery } from "./reasoning"; -import { repairUrlCitationAnnotations, scrubOcxCompactionItems, stripXaiUnsupportedInclude, stripCanonicalOnlyInputItems, stripCanonicalOnlyToolFields, stripCanonicalOnlyTopLevelFields, stripInternalChatMessageMetadataPassthrough, stripInvalidItemIds, stripItemIdsWhenUnstored, stripProviderInputItemMetadata, stripRejectedSamplingParams } from "./request-strips"; +import { repairUrlCitationAnnotations, scrubOcxCompactionItems, stripUnsupportedOutputInclude, stripCanonicalOnlyInputItems, stripCanonicalOnlyToolFields, stripCanonicalOnlyTopLevelFields, stripInternalChatMessageMetadataPassthrough, stripInvalidItemIds, stripItemIdsWhenUnstored, stripProviderInputItemMetadata, stripRejectedSamplingParams } from "./request-strips"; import { notePlaintextReasoningDropped } from "./plaintext-reasoning-notice"; import { stripCanonicalForwardPromptCacheOptions, stripDeprecatedPromptCacheRetention } from "./prompt-cache"; import { isPlainObject } from "./internal"; @@ -48,11 +48,13 @@ import { usageFromResponsesPayload } from "./usage"; import { normalizeToolSchemas, promoteClientLoadedTools, stripUnsupportedHostedTools } from "./tool-schema"; import { annotateEmptyResponsesToolOutputs, backfillWebSearchQueries, normalizeResponsesToolResultAdjacency, repairOrphanedInputItems, repairOversizedReplayCallIds, repairUnidentifiedToolOutputItems, restoreBridgedWebSearchCalls } from "./tool-output-recovery"; import { bridgeSearchReplayScope } from "../../responses/bridge-search-replay-cache"; +import { tagHistoricalInputItems } from "../../responses/item-provenance"; import { applyTierDecisionToResponsesBody, normalizeCanonicalForwardContinuationEnvelope, normalizeCanonicalForwardPromptEnvelope, stripCanonicalForwardSamplingParams, stripPreviousResponseId, stripStatefulResponsesParams, stripUnsupportedForwardParams } from "./canonical-forward"; import { normalizeImageGenClientTools, preferConfiguredHostedTools } from "./image-gen"; import { stripMuseSparkUnsupportedWebSearchFields, stripOpenAiOnlyWebSearchFields } from "./web-search"; import { observeOutbound } from "../../usage/cache-diagnostic"; +import { normalizeMuseToolCatalog } from "./muse-tool-catalog"; import { normalizeMuseToolChoice } from "./muse-tool-choice"; import { declaresHostedWebSearch, projectHostedSearchHistory, renderCompactionSearchHistoryItems } from "./compaction-search-history"; import { stripKimiEmptyAssistantMessages } from "./empty-assistant"; @@ -174,10 +176,27 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): let plaintextV2AgentMessageAliasedToolNames: ReadonlySet | undefined; let convertedMuseToolNameAliases: Map | undefined; const unexpandedMiss = !!parsed.previousResponseId && parsed._previousResponseInputExpanded !== true; - let outBody = stripPreviousResponseId( - parsed._rawBody, + const replayPrefixLen = parsed._replayPrefixLen; + let outBody = parsed._rawBody; + // Clone and tag before any input rewrite can remove, replace, or reorder replay items. + if ( + typeof replayPrefixLen === "number" + && replayPrefixLen > 0 + && isPlainObject(outBody) + && Array.isArray(outBody.input) + ) { + outBody = { + ...outBody, + input: tagHistoricalInputItems(outBody.input, replayPrefixLen), + }; + } + outBody = stripPreviousResponseId( + outBody, forward || parsed._previousResponseInputExpanded === true, ); + if (isMetaAiResponsesDestination(url)) { + outBody = normalizeMuseToolCatalog(outBody, parsed._replayPrefixLen, provider.supportsResponsesCustomTools); + } if (!forward) outBody = normalizeRoutedAgentMessages(outBody, { allowStringContent: isXaiResponsesDestination(provider), }); @@ -308,7 +327,8 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): outBody = stripCanonicalOnlyTopLevelFields(outBody); outBody = stripCanonicalOnlyInputItems(outBody); } - outBody = promoteClientLoadedTools(outBody); + // Meta already captured discoveries from the original current-turn slice. + if (!isMetaAiResponsesDestination(url)) outBody = promoteClientLoadedTools(outBody); } if (!isCanonicalOpenAiForwardProvider(provider)) { const rewritten = rewriteRoutedCustomToolsForUpstream( @@ -338,10 +358,13 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): // OpenCode Go speaks the same dialect, which the final model id and URL identify. outBody = normalizeXaiResponsesWebSearch(outBody, provider, { modelId: parsed.modelId, responseUrl: url }); outBody = injectXaiResponsesXSearch(outBody, provider, parsed._replayPrefixLen); - // xAI answers 400 for `output.encrypted_content` in `include` instead of ignoring it; + // xAI, Meta and Azure answer 400 for `output.encrypted_content` instead of ignoring it; // Codex Desktop asks for it on every turn (#7878). - if (isXaiResponsesDestination(provider)) { - outBody = stripXaiUnsupportedInclude(outBody); + // Azure also rejects this exact include entry, including on plain Responses routes. + const isAzureResponses = provider.adapter === "azure" || provider.adapter === "azure-openai" + || new URL(url).hostname.toLowerCase().endsWith(".openai.azure.com"); + if (isXaiResponsesDestination(provider) || isMetaAiResponsesDestination(url) || isAzureResponses) { + outBody = stripUnsupportedOutputInclude(outBody); } // xAI and explicitly classified compatible gateways reject these OpenAI web_search // extensions. Keep them for OpenAI API-key traffic and unclassified gateways. @@ -360,8 +383,15 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): // Last, so promoted namespace children are also cleared of Codex-private fields. outBody = stripCanonicalOnlyToolFields(outBody, provider.supportsOpenAiWebSearchToolFields === false); } - if (!forward) { - outBody = normalizeOpenCodeGoAdditionalTools(outBody, url, parsed._replayPrefixLen); + if ( + provider.promoteAdditionalTools === true + || (!forward && ( + isOpenCodeGoResponsesUrl(url) + || isXaiResponsesDestination(provider) + || isXaiResponsesUrl(url) + )) + ) { + outBody = promoteResponsesAdditionalTools(outBody); } // Same native-v2/portable predicate as the routedCompaction gate in handleResponses(): // neither forward auth nor native v1 endpoint support implies trigger support. The diff --git a/src/adapters/openai-responses/prompt-cache.ts b/src/adapters/openai-responses/prompt-cache.ts index dff927dd17f..a9b4b2a6e63 100644 --- a/src/adapters/openai-responses/prompt-cache.ts +++ b/src/adapters/openai-responses/prompt-cache.ts @@ -71,7 +71,9 @@ export function stripPromptCacheBreakpoints( if (!isPlainObject(value)) return { value, changed: false, complete: true }; let changed = Object.hasOwn(value, "prompt_cache_breakpoint"); - const next: Record = {}; + // Keep symbol provenance when rebuilding an input item or one of its nested fields. + const next: Record = { ...value }; + delete next.prompt_cache_breakpoint; for (const [key, entry] of Object.entries(value)) { if (key === "prompt_cache_breakpoint") continue; const rewritten = stripPromptCacheBreakpoints(entry, state, depth + 1); diff --git a/src/adapters/openai-responses/request-strips.ts b/src/adapters/openai-responses/request-strips.ts index f7e9e5f2580..9d64f6ed49e 100644 --- a/src/adapters/openai-responses/request-strips.ts +++ b/src/adapters/openai-responses/request-strips.ts @@ -8,24 +8,13 @@ import { activateDeferredTool } from "./tool-schema"; import { stripOpenAiOnlyWebSearchFields } from "./web-search"; /** - * Provider-opaque replay metadata is client history, not an OpenAI input-item field (#7111). - * The caller gates this on OpenAI-operated destinations; arbitrary gateways may accept it. - * Strip only the known extension, never an item-field allowlist that can erase newer wire fields - * or nested tool data. Copy on write so a later Google route can still replay its signature. + * Codex requests output.encrypted_content, which xAI, Meta and Azure reject with HTTP 400. + * The caller gates this on the destination. Preserve reasoning.encrypted_content + * for stateless replay and leave unknown or malformed values for upstream validation. */ -/** Responses `include` entries xAI rejects with a 400 rather than ignoring (#7878). */ -const XAI_UNSUPPORTED_RESPONSES_INCLUDE = new Set(["output.encrypted_content"]); - -/** - * Drop `include` entries xAI's Responses API refuses (`Argument not supported: - * "output.encrypted_content" in "include" field`). Codex Desktop always asks for the - * OpenAI reasoning blob, so without this every xai/grok-4.7 turn sent through the - * Responses path fails, while the same model works on the openai-chat adapter. - * Copy on write; other destinations and other include entries are untouched. - */ -export function stripXaiUnsupportedInclude(body: unknown): unknown { +export function stripUnsupportedOutputInclude(body: unknown): unknown { if (!isPlainObject(body) || !Array.isArray(body.include)) return body; - const include = body.include.filter(item => !XAI_UNSUPPORTED_RESPONSES_INCLUDE.has(String(item))); + const include = body.include.filter(item => item !== "output.encrypted_content"); if (include.length === body.include.length) return body; if (include.length === 0) { const { include: _dropped, ...rest } = body; @@ -34,6 +23,12 @@ export function stripXaiUnsupportedInclude(body: unknown): unknown { return { ...body, include }; } +/** + * Provider-opaque replay metadata is client history, not an OpenAI input-item field (#7111). + * The caller gates this on OpenAI-operated destinations; arbitrary gateways may accept it. + * Strip only the known extension, never an item-field allowlist that can erase newer wire fields + * or nested tool data. Copy on write so a later Google route can still replay its signature. + */ export function stripProviderInputItemMetadata(body: unknown): unknown { if (!isPlainObject(body) || !Array.isArray(body.input)) return body; let input: unknown[] | undefined; @@ -292,12 +287,15 @@ const CANONICAL_ONLY_INPUT_ITEM_TYPES: ReadonlySet = new Set([ * {@link stripCanonicalOnlyTopLevelFields}; the canonical and OpenAI-operated destinations keep * the items verbatim and in order. Returns the input unchanged when none is present. */ +export function isCanonicalOnlyInputItem(item: unknown): boolean { + return isPlainObject(item) + && typeof item.type === "string" && CANONICAL_ONLY_INPUT_ITEM_TYPES.has(item.type); +} + export function stripCanonicalOnlyInputItems(body: unknown): unknown { if (!isPlainObject(body) || !Array.isArray(body.input)) return body; - const isPrivate = (item: unknown): boolean => isPlainObject(item) - && typeof item.type === "string" && CANONICAL_ONLY_INPUT_ITEM_TYPES.has(item.type); - if (!body.input.some(isPrivate)) return body; - return { ...body, input: body.input.filter(item => !isPrivate(item)) }; + if (!body.input.some(isCanonicalOnlyInputItem)) return body; + return { ...body, input: body.input.filter(item => !isCanonicalOnlyInputItem(item)) }; } /** diff --git a/src/adapters/openai-responses/tool-output-recovery.ts b/src/adapters/openai-responses/tool-output-recovery.ts index f2218ecb181..2605e1fdab7 100644 --- a/src/adapters/openai-responses/tool-output-recovery.ts +++ b/src/adapters/openai-responses/tool-output-recovery.ts @@ -3,6 +3,7 @@ import { EMPTY_TOOL_OUTPUT_ANNOTATION, isWhitespaceOnlyTextPartArray } from "../ import { isPlainObject } from "./internal"; import { peekBridgeSearchReplay } from "../../responses/bridge-search-replay-cache"; import { externalTaskInputResponsesContent } from "../../responses/task-input"; +import { copyItemProvenance } from "../../responses/item-provenance"; const MAX_RESPONSES_CALL_ID_LENGTH = 64; @@ -336,7 +337,7 @@ export function restoreBridgedWebSearchCalls(body: unknown, destinationScope: st if (memo && !occupiedCallIds.has(memo.callId)) { changed = true; occupiedCallIds.add(memo.callId); - restored.push({ + const fnCall: Record = { type: "function_call", ...(memo.sourceItemId ? { id: memo.sourceItemId } : {}), call_id: memo.callId, @@ -344,8 +345,16 @@ export function restoreBridgedWebSearchCalls(body: unknown, destinationScope: st // The bridge records the complete arguments text from the call's own done frame; the // empty-object fallback matches what a continuation leg would have sent. arguments: memo.argumentsText.length > 0 ? memo.argumentsText : "{}", - }); - restored.push({ type: "function_call_output", call_id: memo.callId, output: memo.output }); + }; + const fnOutput: Record = { + type: "function_call_output", + call_id: memo.callId, + output: memo.output, + }; + copyItemProvenance(item, fnCall); + copyItemProvenance(item, fnOutput); + restored.push(fnCall); + restored.push(fnOutput); continue; } } @@ -397,7 +406,7 @@ export function repairOrphanedInputItems( const taskInput = isFnOutput && !hasStringCallId ? externalTaskInputResponsesContent(item) : undefined; if (taskInput) { changed = true; - repaired.push({ type: "message", role: "user", content: taskInput }); + repaired.push(copyItemProvenance(item, { type: "message", role: "user", content: taskInput })); continue; } const callId = typeof item.call_id === "string" ? item.call_id : ""; @@ -409,11 +418,13 @@ export function repairOrphanedInputItems( const knownNullOutput = callId.length > 0 && item.output == null; if (repairOrphanOutputs && !paired && (knownNullOutput || usableOutput)) { changed = true; - repaired.push({ + const msg: Record = { type: "message", role: "user", content: orphanedToolOutputContent(item.output, callId), - }); + }; + copyItemProvenance(item, msg); + repaired.push(msg); continue; } } @@ -429,9 +440,11 @@ export function repairOrphanedInputItems( const name = typeof item.name === "string" && item.name.length > 0 ? item.name : callId; const text = `[ocx] no tool result was recorded for "${name}"; execution status unknown — do not treat this as success, failure, or user-provided input.`; syntheticKeys.add(`${isFnCall ? "function" : "custom"}:${callId}`); - pendingSyntheticOutputs.push(isFnCall + const synthetic: Record = isFnCall ? { type: "function_call_output", call_id: callId, output: text } - : { type: "custom_tool_call_output", call_id: callId, output: text }); + : { type: "custom_tool_call_output", call_id: callId, output: text }; + copyItemProvenance(item, synthetic); + pendingSyntheticOutputs.push(synthetic); } } continue; diff --git a/src/adapters/openai-responses/tool-schema.ts b/src/adapters/openai-responses/tool-schema.ts index 12d11093cdd..eb986999fb8 100644 --- a/src/adapters/openai-responses/tool-schema.ts +++ b/src/adapters/openai-responses/tool-schema.ts @@ -8,6 +8,7 @@ import { XaiToolSchemaCompatibilityError, } from "../xai-tool-schema"; import { isPlainObject } from "./internal"; +import { hasItemProvenance, isHistoricalItem } from "../../responses/item-provenance"; function normalizeFunctionToolSchema(tool: unknown, xaiTarget: boolean): unknown | undefined { if (!isPlainObject(tool) || tool.type !== "function") return tool; @@ -195,11 +196,19 @@ function mergeLoadedTools(declaredTools: unknown[], loadedTools: unknown[]): unk * serializing the raw request, so activate those returned definitions for upstreams that do not * implement the native deferred-loading handshake themselves. */ -export function promoteClientLoadedTools(body: unknown): unknown { +export function promoteClientLoadedTools(body: unknown, replayPrefixLength?: number): unknown { if (!isPlainObject(body) || !Array.isArray(body.input)) return body; - const loadedTools = body.input.flatMap(item => - isPlainObject(item) && item.type === "tool_search_output" && Array.isArray(item.tools) + const currentTurnStart = Number.isFinite(replayPrefixLength) + ? Math.max(0, Math.trunc(replayPrefixLength!)) + : 0; + const hasProvenance = body.input.some(hasItemProvenance); + const isCurrentItem = (item: unknown, index: number): boolean => + hasProvenance ? !isHistoricalItem(item) : index >= currentTurnStart; + + // Filter the source before declarations lose their item's provenance in a live catalog. + const loadedTools = body.input.flatMap((item, index) => + isCurrentItem(item, index) && isPlainObject(item) && item.type === "tool_search_output" && Array.isArray(item.tools) ? item.tools : [] ); @@ -210,17 +219,20 @@ export function promoteClientLoadedTools(body: unknown): unknown { return tools === body.tools ? body : { ...body, tools }; } - const additionalToolsIndex = body.input.findIndex(item => - isPlainObject(item) && item.type === "additional_tools" && Array.isArray(item.tools) + const currentAdditionalToolsIndex = body.input.findIndex((item, index) => + isCurrentItem(item, index) && isPlainObject(item) && item.type === "additional_tools" && Array.isArray(item.tools) ); - if (additionalToolsIndex < 0) return { ...body, tools: mergeLoadedTools([], loadedTools) }; - const additionalTools = body.input[additionalToolsIndex]; + if (currentAdditionalToolsIndex < 0) { + return { ...body, tools: mergeLoadedTools([], loadedTools) }; + } + + const additionalTools = body.input[currentAdditionalToolsIndex]; if (!isPlainObject(additionalTools) || !Array.isArray(additionalTools.tools)) return body; const tools = mergeLoadedTools(additionalTools.tools, loadedTools); if (tools === additionalTools.tools) return body; const input = [...body.input]; - input[additionalToolsIndex] = { ...additionalTools, tools }; + input[currentAdditionalToolsIndex] = { ...additionalTools, tools }; return { ...body, input }; } diff --git a/src/adapters/opencode-go-additional-tools.ts b/src/adapters/opencode-go-additional-tools.ts index ea41ec1aa17..c76b0ad8a00 100644 --- a/src/adapters/opencode-go-additional-tools.ts +++ b/src/adapters/opencode-go-additional-tools.ts @@ -1,3 +1,5 @@ +import { hasItemProvenance, isHistoricalItem } from "../responses/item-provenance"; + function isRecord(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } @@ -20,13 +22,32 @@ export function isOpenCodeGoResponsesUrl(responseUrl: string): boolean { && !destination.href.includes("?") && !destination.href.includes("#"); } -/** Console Go accepts public tools but rejects the private additional_tools input wrapper. */ -export function normalizeOpenCodeGoAdditionalTools( +const XAI_RESPONSES_HOSTS = new Set(["api.x.ai", "cli-chat-proxy.grok.com"]); + +/** + * True when the Responses target is an official xAI endpoint (public API or Grok CLI proxy). + */ +export function isXaiResponsesUrl(responseUrl: string): boolean { + let destination: URL; + try { + destination = new URL(responseUrl); + } catch { + return false; + } + return destination.protocol === "https:" + && XAI_RESPONSES_HOSTS.has(destination.hostname.toLowerCase()) + && (destination.port === "" || destination.port === "443"); +} + +/** + * Promotes client-loaded `additional_tools` input items into top-level `tools` and strips + * the wrapper from `input`. Required for gateways whose Rust/OpenAI-strict deserializer + * rejects unknown items in the `input` array. + */ +export function promoteResponsesAdditionalTools( body: unknown, - responseUrl: string, replayPrefixLength = 0, ): unknown { - if (!isOpenCodeGoResponsesUrl(responseUrl)) return body; if (!isRecord(body) || !Array.isArray(body.input)) return body; // Do not replace a malformed top-level catalog with a partial promoted one. if (body.tools !== undefined && !Array.isArray(body.tools)) return body; @@ -36,13 +57,15 @@ export function normalizeOpenCodeGoAdditionalTools( const currentTurnStart = Number.isFinite(replayPrefixLength) ? Math.min(body.input.length, Math.max(0, Math.trunc(replayPrefixLength))) : 0; + const hasProvenance = body.input.some(hasItemProvenance); let changed = false; for (const [index, item] of body.input.entries()) { if (isRecord(item) && item.type === "additional_tools" && Array.isArray(item.tools)) { changed = true; // Replayed wrappers are conversation history, not authority for the current request. - // Go cannot accept the wrapper itself, so remove it without promoting its catalog. - if (index < currentTurnStart) continue; + // Gateways cannot accept the wrapper itself, so remove it without promoting its catalog. + const isHistorical = hasProvenance ? isHistoricalItem(item) : (index < currentTurnStart); + if (isHistorical) continue; // Custom/search/namespace lowering already owns identity and deduplication. This pass // only moves declarations, including hosted tools that intentionally have no name. for (const tool of item.tools) promoted.push(tool); @@ -52,3 +75,13 @@ export function normalizeOpenCodeGoAdditionalTools( } return changed ? { ...body, input, tools: [...(body.tools ?? []), ...promoted] } : body; } + +/** Console Go accepts public tools but rejects the private additional_tools input wrapper. */ +export function normalizeOpenCodeGoAdditionalTools( + body: unknown, + responseUrl: string, + replayPrefixLength = 0, +): unknown { + if (!isOpenCodeGoResponsesUrl(responseUrl)) return body; + return promoteResponsesAdditionalTools(body, replayPrefixLength); +} diff --git a/src/adapters/routed-agent-messages.ts b/src/adapters/routed-agent-messages.ts index 2ca67162a72..e9c57d17cc6 100644 --- a/src/adapters/routed-agent-messages.ts +++ b/src/adapters/routed-agent-messages.ts @@ -1,3 +1,5 @@ +import { hasItemProvenance, isHistoricalItem, REPLAY_HISTORICAL_ITEM } from "../responses/item-provenance"; + /** * `agent_message` is Codex's private multi-agent input item: it exists only in the ChatGPT * Codex backend's schema. Codex replays every sub-agent reply in the history it sends, so @@ -36,6 +38,7 @@ export function normalizeRoutedAgentMessages( changed = true; return { type: "message", role: "user", + ...(hasItemProvenance(message) ? { [REPLAY_HISTORICAL_ITEM]: isHistoricalItem(message) } : {}), content: [ ...(Object.keys(identities).length ? [{ type: "input_text", text: `Agent message ${JSON.stringify(identities)}` }] : []), ...content, diff --git a/src/adapters/xai-web-search.ts b/src/adapters/xai-web-search.ts index 836eb0ca6df..d871234ae73 100644 --- a/src/adapters/xai-web-search.ts +++ b/src/adapters/xai-web-search.ts @@ -2,6 +2,7 @@ import type { OcxProviderConfig } from "../types"; import { debugProviderDiagnostic } from "../lib/debug"; import { isXaiResponsesDestination } from "../providers/xai-transport"; import { isOpenCodeGoResponsesUrl } from "./opencode-go-additional-tools"; +import { hasItemProvenance, isHistoricalItem } from "../responses/item-provenance"; const CODEX_WEB_SEARCH_TOOL = "web_search"; const CODEX_WEB_SEARCH_PREVIEW_TOOL = "web_search_preview"; @@ -190,8 +191,8 @@ export function normalizeXaiResponsesWebSearch( const xaiDestination = isXaiResponsesDestination(provider); if (!xaiDestination && !isOpenCodeGoGrokResponses(target?.modelId, target?.responseUrl)) return body; // On Go, a wrapper emptied here stays in place: Go promotion (opencode-go-additional-tools.ts) - // removes every wrapper before the wire and splits them into history and current turn by - // index against _replayPrefixLen, so deleting one would shift the current turn into history. + // removes every wrapper before the wire. Retain positions for callers with untagged input; + // transformed adapter input carries per-item historical/live provenance instead. const keepEmptiedWrappers = !xaiDestination; let next: Record = body; @@ -264,7 +265,9 @@ export function injectXaiResponsesXSearch( const input = Array.isArray(body.input) ? body.input : undefined; const inputStart = input ? currentInputStart(input.length, replayPrefixLength) : 0; - const currentInput = input?.slice(inputStart) ?? []; + const currentInput = input?.some(hasItemProvenance) + ? input.filter(item => !isHistoricalItem(item)) + : input?.slice(inputStart) ?? []; const currentXSearchDeclared = hasToolType(body.tools, XAI_SEARCH_TOOL) || currentInput.some(item => isPlainObject(item) diff --git a/src/adapters/zed-additional-tools.ts b/src/adapters/zed-additional-tools.ts index 73a18ca4533..10b52677e52 100644 --- a/src/adapters/zed-additional-tools.ts +++ b/src/adapters/zed-additional-tools.ts @@ -1,4 +1,5 @@ import { isPlainObject as isRecord } from "./openai-responses/internal"; +import { hasItemProvenance, isHistoricalItem, tagHistoricalInputItems } from "../responses/item-provenance"; function toolKey(tool: Record): string | undefined { if (typeof tool.type !== "string" || !tool.type) return undefined; @@ -8,8 +9,8 @@ function toolKey(tool: Record): string | undefined { /** * Zed's OpenAI Responses schema accepts message and call items, not Codex's private - * additional_tools wrapper. Drop every wrapper. Promote only wrappers at or after the - * replayed prefix: earlier ones are history, not the current turn's tool catalog. + * additional_tools wrapper. Drop every wrapper. Item provenance owns current-turn + * membership; a numeric prefix is accepted only for input not yet tagged. * A non-array tools field is left untouched so a malformed catalog is not replaced * by a partial promotion. */ @@ -17,16 +18,15 @@ export function liftZedAdditionalTools(providerRequest: Record, if (!Array.isArray(providerRequest.input)) return; if (providerRequest.tools !== undefined && !Array.isArray(providerRequest.tools)) return; - const currentTurnStart = Number.isFinite(replayPrefixLength) - ? Math.min(providerRequest.input.length, Math.max(0, Math.trunc(replayPrefixLength))) - : 0; + const taggedInput = providerRequest.input.some(hasItemProvenance) ? providerRequest.input + : tagHistoricalInputItems(providerRequest.input, Number.isFinite(replayPrefixLength) ? replayPrefixLength : 0); const input: unknown[] = []; const promoted: unknown[] = []; let changed = false; - for (const [index, item] of providerRequest.input.entries()) { + for (const item of taggedInput) { if (isRecord(item) && item.type === "additional_tools" && Array.isArray(item.tools)) { changed = true; - if (index >= currentTurnStart) promoted.push(...item.tools); + if (!isHistoricalItem(item)) promoted.push(...item.tools); continue; } input.push(item); diff --git a/src/adapters/zed.ts b/src/adapters/zed.ts index a9bb6104df4..e156a59748c 100644 --- a/src/adapters/zed.ts +++ b/src/adapters/zed.ts @@ -354,12 +354,7 @@ export function createZedAdapter(provider: OcxProviderConfig, cacheRetention?: A : item, ); } - if (zedProvider === "open_ai") { - // Codex carries this turn's client tools in a private additional_tools input item. - // Zed rejects that variant, so promote the current turn and drop every wrapper - // before the built-in tool filter runs. - liftZedAdditionalTools(providerRequest, parsed._replayPrefixLen ?? 0); - } + // OpenAI wrappers were promoted before delegation; serialized input has no provenance tags. if (zedProvider === "open_ai" && Array.isArray(providerRequest.tools)) { // Zed's Responses decoder supports function/custom tools, not Codex's built-in web_search. const supported = (tool: unknown) => isRecord(tool) && (tool.type === "function" || tool.type === "custom"); diff --git a/src/chatgpt/app-server-shim/bundle-trust-policy.ts b/src/chatgpt/app-server-shim/bundle-trust-policy.ts index 4bb2c1b93a6..b105b1b28b1 100644 --- a/src/chatgpt/app-server-shim/bundle-trust-policy.ts +++ b/src/chatgpt/app-server-shim/bundle-trust-policy.ts @@ -28,6 +28,11 @@ export function bundleTrustStat(path: string): ReturnType; +/** The inline bundle root is code: protect its file and directory entry like the bundle. */ +export function untrustedLauncherFilesystemReason(path: string, deps: FilesystemTrustDeps, existing = true): string | null { + return (existing ? ownedSafely(deps, path, "file") : null) ?? untrustedAncestorReason(path, deps); +} + function ownedSafely(deps: FilesystemTrustDeps, path: string, kind: "file" | "directory"): string | null { const s = deps.stat(path); if (!s) return "is missing"; diff --git a/src/chatgpt/app-server-shim/launcher.ts b/src/chatgpt/app-server-shim/launcher.ts index 53142ddafbf..6cddfd97c0f 100644 --- a/src/chatgpt/app-server-shim/launcher.ts +++ b/src/chatgpt/app-server-shim/launcher.ts @@ -3,6 +3,8 @@ import { randomBytes } from "node:crypto"; import { join, posix } from "node:path"; import { getConfigDir } from "../../config/paths"; import { selfLaunchArgv } from "../../lib/self-launch-argv"; +import { defaultBundleTrustDeps } from "./bundle-trust"; +import { untrustedLauncherFilesystemReason, type BundleTrustDeps } from "./bundle-trust-policy"; export const CHATGPT_APP_CODEX_BINARY = "/Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex"; @@ -47,24 +49,41 @@ function shellQuote(value: string): string { } /** - * A failed precondition or a failed self-test runs the original binary with untouched stdout. + * A failed filter self-test runs the verified binary with untouched stdout. + * Executable verification is required on every invocation, including passthrough and respawns. * A filter that passes the self-test and then dies mid-session closes the pipe. * Expected (not yet validated against the bundled app-server): the server gets SIGPIPE or a * write error and Desktop respawns it through the same launcher. */ -export function buildChatgptShimLauncher(argv: readonly string[], real = CHATGPT_APP_CODEX_BINARY): string { +export function buildChatgptShimLauncher( + argv: readonly string[], + real = CHATGPT_APP_CODEX_BINARY, + validator: readonly string[] = [process.execPath, ...selfLaunchArgv(["internal", "codex-exec-target"])], + bundleRoot = /^(.*?\.app)\/Contents\//i.exec(real)?.[1], +): string { return `#!/bin/bash # opencodex (experimental): ChatGPT app-server stdout passes through the quota-gate filter. REAL=${shellQuote(real)} -FILTER=(${argv.map(shellQuote).join(" ")}) +${bundleRoot === undefined ? "" : `BUNDLE_ROOT=${shellQuote(bundleRoot)}\n`}FILTER=(${argv.map(shellQuote).join(" ")}) +VALIDATE=(${validator.map(shellQuote).join(" ")}) if [ ! -x "$REAL" ]; then echo "opencodex (experimental): the bundled app-server is no longer at $REAL; run 'ocx chatgpt launch' again or 'ocx chatgpt restore'." >&2 exit 127 fi +validate_real() { + local target + if [ ! -x "\${VALIDATE[0]}" ] || ! target=$("\${VALIDATE[@]}" ${bundleRoot === undefined ? "" : '--bundle-root "$BUNDLE_ROOT" '}-- "$REAL") || [ -z "$target" ]; then + echo "opencodex: app-server execution verification failed; run 'ocx chatgpt launch' again or 'ocx chatgpt restore'." >&2 + return 126 + fi + REAL="$target" +} if [ "$(uname -s)" = "Darwin" ] && [ -x "\${FILTER[0]}" ] \\ && "\${FILTER[@]}" --self-test >/dev/null 2>&1; then + validate_real || exit 126 exec "$REAL" "$@" > >(exec "\${FILTER[@]}") fi +validate_real || exit 126 exec "$REAL" "$@" `; } @@ -74,17 +93,32 @@ exec "$REAL" "$@" * launcher, so an existing symbolic link is replaced rather than followed and a respawn during * the write never runs a half-written script. */ -export function writeChatgptShimLauncher(configDir = getConfigDir(), real = CHATGPT_APP_CODEX_BINARY): string { +export function writeChatgptShimLauncher( + configDir = getConfigDir(), real = CHATGPT_APP_CODEX_BINARY, + bundleRoot = /^(.*?\.app)\/Contents\//i.exec(real)?.[1], + filesystem: Pick | undefined = process.platform === "darwin" ? defaultBundleTrustDeps() : undefined, +): string { + if (!bundleRoot || !posix.isAbsolute(bundleRoot) || !real.startsWith(bundleRoot + "/Contents/")) { + throw new Error("the app-server launcher requires its discovered bundle root"); + } mkdirSync(configDir, { recursive: true }); const path = chatgptShimLauncherPath(configDir); const argv = [process.execPath, ...selfLaunchArgv(["internal", "chatgpt-app-server-filter"])]; const temp = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`; + let created = false; + const assertFilesystem = (file: string, existing: boolean) => { + const reason = filesystem && untrustedLauncherFilesystemReason(file, filesystem, existing); + if (reason) throw new Error(`unsafe app-server launcher: ${reason}`); + }; try { - writeFileSync(temp, buildChatgptShimLauncher(argv, real), { mode: 0o755, flag: "wx" }); + assertFilesystem(temp, false); + writeFileSync(temp, buildChatgptShimLauncher(argv, real, undefined, bundleRoot), { mode: 0o755, flag: "wx" }); + created = true; chmodSync(temp, 0o755); + assertFilesystem(temp, true); renameSync(temp, path); } catch (error) { - try { unlinkSync(temp); } catch { /* the temp file was never created */ } + if (created) { try { unlinkSync(temp); } catch { /* preserve the publication error */ } } throw error; } return path; diff --git a/src/cli/account-main.ts b/src/cli/account-main.ts index 08978824f10..70b2dd2cc57 100644 --- a/src/cli/account-main.ts +++ b/src/cli/account-main.ts @@ -3,6 +3,7 @@ import { redactSecretArgs } from "./secret-args"; import { codexExecInvocation } from "../codex/exec-invocation"; import { validatedCodexExecInvocationAsync, type CodexExecBoundaryAsyncDeps } from "../codex/exec-boundary"; import { resolveAndPersistCodexRuntime, type ResolveCodexRuntimeDeps } from "../codex/runtime"; +import { withCodexCliSelectionParentWriteAsync } from "../codex/cli-update-selection-gate"; import type { ResolveDeps, SpawnInvocation } from "../lib/win-exec"; import { apiError, @@ -62,7 +63,7 @@ export async function nativeMainCodexLoginValidatedInvocation( deps: NativeMainCodexLoginValidatedDeps = {}, ): Promise { const env = deps.env ?? process.env; - const command = resolveAndPersistCodexRuntime({ + const command = (await withCodexCliSelectionParentWriteAsync(() => resolveAndPersistCodexRuntime({ env, platform, existsSync: deps.existsSync, @@ -70,7 +71,7 @@ export async function nativeMainCodexLoginValidatedInvocation( configDir: deps.configDir, readFileSync: deps.readFileSync, discoverAlternatives: false, - }).runtime.command || "codex"; + }), { configDir: deps.configDir, signal: deps.signal })).runtime.command || "codex"; return validatedCodexExecInvocationAsync(command, ["login"], { env, platform, diff --git a/src/cli/chatgpt-command.ts b/src/cli/chatgpt-command.ts index 3006f44638c..0436b3033b1 100644 --- a/src/cli/chatgpt-command.ts +++ b/src/cli/chatgpt-command.ts @@ -164,7 +164,7 @@ CODEX_CLI_PATH launcher: ${app.shim === null ? "unknown" : app.shim ? "yes" : "n console.error("ChatGPT process inspection failed; no launcher was changed and no app was opened. Retry after resolving inspection."); return 1; } - if (binary) writeChatgptShimLauncher(undefined, binary); + if (binary) writeChatgptShimLauncher(undefined, binary, install.root); if (!(await quitApp(install, launcher))) { console.error("ChatGPT exit could not be confirmed; no new app was opened. Check the running app and retry."); return 1; diff --git a/src/cli/codex-launcher.ts b/src/cli/codex-launcher.ts index 98fc9f103ea..d8fce9489db 100644 --- a/src/cli/codex-launcher.ts +++ b/src/cli/codex-launcher.ts @@ -25,13 +25,13 @@ import { spawn } from "node:child_process"; import { loadConfig } from "../config"; import { getCodexRoutingKind, type CodexRoutingKind } from "../codex/inject"; import { inspectCodexShimBackingForCommand } from "../codex/shim"; +import { validatedCodexExecInvocationAsync, type CodexExecBoundaryAsyncDeps } from "../codex/exec-boundary"; import { CODEX_GLOBAL_OPTIONS_WITH_VALUE, CODEX_INTERNAL_COMMANDS, CODEX_SHIM_ENSURE_FAILED_DIAGNOSTIC } from "../codex/shim-templates"; import { withoutSiblingMarker } from "../codex/sibling-start"; import { withProcessRuntimeProvenance } from "../lib/bun-runtime"; import { tokenCollidesWithAdmin } from "../lib/admin-secrets"; import { selfLaunchArgv } from "../lib/self-launch-argv"; import { loadServiceTokenFromFile, serviceApiTokenFilePath } from "../lib/service-secrets"; -import { commandInvocation } from "../lib/win-exec"; import { findExecutableOnPath } from "../remote-control/workspace-executable"; import { findLiveProxy, type LiveProxy } from "../server/proxy-liveness"; import { codexCredentialDestinationIsTrusted, resolveCodexLaunchHome } from "./codex-launch-auth"; @@ -265,14 +265,26 @@ export function resolveCodexLaunchCommand( } } -/** Launch the selected Codex command with inherited IO and preserve its exit status. */ -function spawnCodex(args: readonly string[], env: CodexLaunchEnv): Promise { +export interface CodexSpawnDeps extends CodexLaunchCommandDeps { + execBoundary?: CodexExecBoundaryAsyncDeps; + spawn?: typeof spawn; +} + +/** Validate the final backing command immediately before each launch, including fast paths. */ +export async function spawnCodex(args: readonly string[], env: CodexLaunchEnv, deps: CodexSpawnDeps = {}): Promise { + let launch: CodexLaunchCommand, inv: Awaited>; + try { + launch = resolveCodexLaunchCommand(env, deps); + inv = await validatedCodexExecInvocationAsync(launch.command, args, { ...deps.execBoundary, verifyAnyAppBundle: true, env: launch.env as NodeJS.ProcessEnv }); + } catch (error) { + // A selected executable that vanished before verification is the same missing CLI + // the spawn-time ENOENT branch below reports, not a trust refusal. + const missing = error instanceof CodexNotFoundError || (error as NodeJS.ErrnoException | null)?.code === "ENOENT"; + console.error(missing ? CODEX_INSTALL_HINT : `❌ Cannot launch codex: ${error instanceof Error ? error.message : "executable verification failed"}`); + return 1; + } return new Promise(resolve => { - let launch: CodexLaunchCommand; - try { launch = resolveCodexLaunchCommand(env); } - catch (error) { console.error(error instanceof CodexNotFoundError ? CODEX_INSTALL_HINT : CODEX_UNVERIFIED_SHIM); resolve(1); return; } - const inv = commandInvocation(launch.command, args); - const child = spawn(inv.file, inv.args, { stdio: "inherit", env: launch.env as NodeJS.ProcessEnv, ...inv.options }); + const child = (deps.spawn ?? spawn)(inv.file, inv.args, { stdio: "inherit", env: launch.env as NodeJS.ProcessEnv, ...inv.options }); child.on("error", (err: NodeJS.ErrnoException) => { if (err.code === "ENOENT") { console.error(CODEX_INSTALL_HINT); diff --git a/src/cli/codex-shim-autorestore.ts b/src/cli/codex-shim-autorestore.ts index 86750714f3b..8ea964ac3c7 100644 --- a/src/cli/codex-shim-autorestore.ts +++ b/src/cli/codex-shim-autorestore.ts @@ -1,4 +1,4 @@ -import { autoRestoreCodexShim } from "../codex/shim"; +import { autoRestoreCodexShim, autoRestoreCodexShimAsync } from "../codex/shim"; import { codexShimAutoRestoreEnabled, readConfigDiagnostics } from "../config"; export interface CodexShimAutoRestoreCliDeps { @@ -21,6 +21,8 @@ export function skipsCodexShimAutoRestore(command: string | undefined, args: str if (command === "status" || command === "doctor") return true; // The launcher owns shim handoff after classifying routing, including native fast paths. if (command === "codex") return true; + // Exec admission only observes the selected target; it must never repair a shim first. + if (command === "internal" && args[1] === "codex-exec-target") return true; // `lab` is read-only inspection; it must not trigger shim side effects. if (command === "lab") return true; // Local messaging owns its helpers; even malformed usage must not repair/start anything. @@ -75,3 +77,19 @@ export function runCodexShimAutoRestore(deps: CodexShimAutoRestoreCliDeps = DEFA ); } } + +export async function maybeAutoRestoreCodexShimAsync(command: string | undefined, args: string[]): Promise { + if (!skipsCodexShimAutoRestore(command, args)) await runCodexShimAutoRestoreAsync(); +} + +export async function runCodexShimAutoRestoreAsync(): Promise { + try { + const result = await autoRestoreCodexShimAsync({ + enabled: () => codexShimAutoRestoreEnabled(DEFAULT_DEPS.readConfig().config, DEFAULT_DEPS.env), + }); + if (result.status === "restored") DEFAULT_DEPS.warn(`⚠️ ${result.message} (automatic repair after Codex update)`); + else if ((result.status === "deferred" || result.status === "ineligible") && result.message) DEFAULT_DEPS.warn(`⚠️ ${result.message}`); + } catch (error) { + DEFAULT_DEPS.warn(`⚠️ Codex shim auto-restore failed; continuing without it: ${error instanceof Error ? error.message : String(error)}. Run 'ocx codex-shim install' after the Codex update finishes.`); + } +} diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 6f9809c9916..d4d3f794166 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -435,8 +435,8 @@ const commandRunners: Record = { if (expectation) { // The root preflight skipped the shim repair for a pinned sync; the identity held, so // run it now, as an unpinned sync would have. - const { runCodexShimAutoRestore } = await import("./codex-shim-autorestore"); - runCodexShimAutoRestore(); + const { runCodexShimAutoRestoreAsync } = await import("./codex-shim-autorestore"); + await runCodexShimAutoRestoreAsync(); } // The connection the sync ran under, read inside its lock; the read above may be stale. const link = result.connection.transport === "link"; @@ -731,10 +731,10 @@ const commandRunners: Record = { return Number(process.exitCode ?? 0); }, "codex-shim": async deps => { - const { codexShimStatus, diagnoseCodexShim, installCodexShim, uninstallCodexShim } = await import("../codex/shim"); + const { codexShimStatus, diagnoseCodexShim, installCodexShimAsync, uninstallCodexShimAsync } = await import("../codex/shim"); switch (deps.args[1]) { case "install": { - const r = installCodexShim(); + const r = await installCodexShimAsync(); const { healthy, runnable, active, summary } = diagnoseCodexShim(); const success = !r.refused && (runnable ?? healthy); const { collectCodexShimReadinessWarnings } = await import("./codex-shim-readiness"); @@ -764,7 +764,7 @@ const commandRunners: Record = { } case "uninstall": case "remove": { - const r = uninstallCodexShim(); + const r = await uninstallCodexShimAsync(); console.log(r.removed ? `✅ ${r.message}` : `⚠️ ${r.message}`); break; } @@ -858,7 +858,7 @@ const commandRunners: Record = { }, ready: async deps => { // Fail-closed impossible-state guard: readyArgs is populated by the - // preparse block in src/cli/root.ts before maybeAutoRestoreCodexShim, so + // preparse block in src/cli/root.ts before maybeAutoRestoreCodexShimAsync, so // reaching here without it means dispatch diverged. Refuse with code 64 // and perform NO I/O (no discovery/probe). process.exit is `never`, // narrowing below. diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 2753a7362f6..ff33b7ab209 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -70,8 +70,8 @@ import { effortClampAppliesToRuntime, liveRemovedEfforts, loadLastEffortClamp, - persistCodexRuntime, - resolveAndPersistCodexRuntime, + persistCodexRuntimeAsync, + resolveAndPersistCodexRuntimeAsync, resolveCodexRuntime, } from "../codex/runtime"; import { CODEX_REAUTH_ACTION, collectOAuthHealthEntriesForCli, MASKED_ACCOUNT_FALLBACK, type OAuthHealthEntry } from "../oauth/health"; @@ -1266,8 +1266,8 @@ export async function runDoctor(args: string[] = []): Promise { const resolved = resolveCodexRuntime(); if (!resolved.newerAvailable) { console.log("No newer Codex runtime found; keeping current selection."); - const current = resolveAndPersistCodexRuntime(); - console.log(`Selected: ${displayCodexRuntimePath(current.runtime.command)} (${current.runtime.version ?? "unknown"})`); + const current = await resolveAndPersistCodexRuntimeAsync(); + console.log(`Selected: ${displayCodexRuntimePath(current?.runtime.command ?? "codex")} (${current?.runtime.version ?? "unknown"})`); return; } if (resolved.runtime.source === "environment") { @@ -1276,7 +1276,7 @@ export async function runDoctor(args: string[] = []): Promise { console.log("Then run ocx sync."); return; } - persistCodexRuntime({ + await persistCodexRuntimeAsync({ command: resolved.newerAvailable.command, version: resolved.newerAvailable.version, source: "configured", diff --git a/src/cli/index.ts b/src/cli/index.ts index d1439754154..52cc993a5fe 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -69,6 +69,7 @@ import { writePid, writeRuntimePort, } from "../config/process-state"; +import { describeTrackedRuntimeTarget } from "../config/tracked-runtime-inspection"; import { claimPendingTeardown, isPendingTeardownAbandoned, @@ -1020,13 +1021,7 @@ async function handleStop(approval?: StopApproval) { try { return JSON.parse(lines[0]!) as ResolveJson; } catch { return null; } }, - () => { - const pid = readPid(); - const runtime = pid === null ? null : readRuntimePort(pid); - return pid && runtime?.port ? { - pid, port: runtime.port, hostname: runtime.hostname ?? "", - } : null; - }, + describeTrackedRuntimeTarget, () => inspectGuardedManagerTarget(approval.pid, approval.port), snapshot => handleStopUnlocked(snapshot), ); @@ -1679,8 +1674,8 @@ async function handleUninstall() { }); try { - const { uninstallCodexShim } = await import("../codex/shim"); - const r = uninstallCodexShim(); + const { uninstallCodexShimAsync } = await import("../codex/shim"); + const r = await uninstallCodexShimAsync(); console.log(r.removed ? "✅ Codex autostart shim removed" : "- Codex autostart shim removed: not installed"); } catch (err) { failures.push("Codex autostart shim removed"); diff --git a/src/cli/init.ts b/src/cli/init.ts index 83a69cff06f..b8866eccb3e 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -257,9 +257,9 @@ export async function runInit(): Promise { prompt.throwIfCancelled(); if (shimAnswer.trim().toLowerCase() !== "n") { try { - const { installCodexShim } = await import("../codex/shim"); + const { installCodexShimAsync } = await import("../codex/shim"); prompt.throwIfCancelled(); - const result = installCodexShim(); + const result = await installCodexShimAsync(); console.log(result.installed ? `✅ ${result.message}` : `⚠️ ${result.message}`); } catch (err) { if (err instanceof InitCancelledError) throw err; diff --git a/src/cli/internal-command.ts b/src/cli/internal-command.ts index 7799ceb52b2..d9d7c2ae9c7 100644 --- a/src/cli/internal-command.ts +++ b/src/cli/internal-command.ts @@ -16,15 +16,42 @@ * that same user, and a same-uid process could call `kill` directly. A token here would * protect nothing and would imply a boundary that does not exist. */ -const USAGE = "Usage: ocx internal desktop-restart-handoff --plan | chatgpt-app-server-filter [--self-test]"; +import type { CodexExecBoundaryAsyncDeps } from "../codex/exec-boundary"; + +const USAGE = "Usage: ocx internal desktop-restart-handoff --plan | chatgpt-app-server-filter [--self-test] | codex-exec-target [--bundle-root ] -- "; function optionValue(args: readonly string[], name: string): string | undefined { const index = args.indexOf(name); return index >= 0 ? args[index + 1] : undefined; } -export async function handleInternalCommand(args: readonly string[]): Promise { +/** + * Dispatch internal helpers with command-specific argument checks and exit codes. + * The executable-target helper writes one trusted path to stdout only after validation. + */ +export async function handleInternalCommand(args: readonly string[], execBoundary: CodexExecBoundaryAsyncDeps = {}): Promise { const sub = args[0]; + if (sub === "codex-exec-target") { + const bundled = args[1] === "--bundle-root"; + const root = bundled ? args[2] : undefined; + const separator = bundled ? 3 : 1; + const command = args[separator + 1]; + if (args.length !== separator + 2 || args[separator] !== "--" || !command || /[\r\n\0]/.test(command) + || (bundled && (!root || !root.startsWith("/") || /[\r\n\0]/.test(root)))) { + console.error(USAGE); + return 2; + } + try { + const { assertCodexExecTargetTrustedAsync } = await import("../codex/exec-boundary"); + const target = await assertCodexExecTargetTrustedAsync(command, bundled ? { ...execBoundary, expectedBundleRoot: root } : execBoundary); + if (!target || /[\r\n]/.test(target)) throw new Error("invalid executable path"); + console.log(target); + return 0; + } catch (error) { + console.error(`OpenCodex refused the app-server executable: ${error instanceof Error ? error.message : "verification failed"}`); + return 1; + } + } if (sub === "chatgpt-app-server-filter") { if (args.length > 2 || (args[1] !== undefined && args[1] !== "--self-test")) { console.error(USAGE); diff --git a/src/cli/restart-scope.ts b/src/cli/restart-scope.ts index 58b5264606e..1574520c192 100644 --- a/src/cli/restart-scope.ts +++ b/src/cli/restart-scope.ts @@ -104,9 +104,9 @@ export async function handleDesktopAppRestart( log: Pick, io: DesktopAppRestartIo = {}, ): Promise { - const { restartCodexDesktopApp } = await import("../codex/desktop-app-restart"); + const { restartCodexDesktopAppAsync } = await import("../codex/desktop-app-restart"); const { startDesktopRestartHandoff } = await import("../codex/desktop-app/handoff"); - const result = restartCodexDesktopApp({ + const result = await restartCodexDesktopAppAsync({ ...io, // The CLI is the one caller whose exit is exactly the signal the helper waits for, // so it is the one caller allowed to hand off. The management service is not (it diff --git a/src/cli/root.ts b/src/cli/root.ts index f34a1cc22ab..2668b1fb8a8 100644 --- a/src/cli/root.ts +++ b/src/cli/root.ts @@ -13,7 +13,7 @@ import { parseReadyArgs, type ReadyArgs } from "./ready"; import { parseResolveArgs, type ResolveArgs } from "./resolve"; import { parseStopApproval } from "./stop-approval"; import { parseSyncExpectation, SYNC_EXPECTATION_USAGE_EXIT } from "./sync-expectation"; -import { maybeAutoRestoreCodexShim } from "./codex-shim-autorestore"; +import { maybeAutoRestoreCodexShimAsync } from "./codex-shim-autorestore"; import { findCommand } from "./registry"; import { printUnknownCommand } from "./help-recovery"; import { noteCredentialArgv, redactSecretArgs } from "./secret-args"; @@ -64,7 +64,7 @@ export function parseCliHead(argv: string[]): CliHead { }; } // P1: pre-parse `ocx ready` and reject invalid arguments with exit 64 BEFORE - // maybeAutoRestoreCodexShim (or any discovery/probe/filesystem-capable global + // maybeAutoRestoreCodexShimAsync (or any discovery/probe/filesystem-capable global // preflight) runs. `ready --help` / `help ready` already exited above, so this // only sees ready args without a help flag. Valid args are stashed so the // switch dispatch can call runReady without a second parse. @@ -74,7 +74,7 @@ export function parseCliHead(argv: string[]): CliHead { return { kind: "ready", command, args, readyArgs: parsed.args }; } // Same ordering contract as `ready`: `ocx resolve` rejects any argument with exit - // 64 BEFORE maybeAutoRestoreCodexShim (or any other preflight with side effects) runs. + // 64 BEFORE maybeAutoRestoreCodexShimAsync (or any other preflight with side effects) runs. if (command === "resolve") { const parsed = parseResolveArgs(args.slice(1)); if (!parsed.ok) return { kind: "resolve", command, args, resolveArgs: undefined }; @@ -115,7 +115,7 @@ export async function runCli(argv: string[]): Promise { console.error(" Default wait timeout is 45 seconds."); process.exit(64); } - maybeAutoRestoreCodexShim(head.command, head.args); + await maybeAutoRestoreCodexShimAsync(head.command, head.args); return head; } case "resolve": { @@ -128,7 +128,7 @@ export async function runCli(argv: string[]): Promise { console.error(" and the identity-checked liveness verdict."); process.exit(64); } - maybeAutoRestoreCodexShim(head.command, head.args); + await maybeAutoRestoreCodexShimAsync(head.command, head.args); return head; } case "command": @@ -154,7 +154,7 @@ export async function runCli(argv: string[]): Promise { } } await maybeNoticeVersionSkew(head.command, head.args); - maybeAutoRestoreCodexShim(head.command, head.args); + await maybeAutoRestoreCodexShimAsync(head.command, head.args); return head; } } diff --git a/src/cli/stop-approval.ts b/src/cli/stop-approval.ts index 4b83fef6169..5a68879e8d5 100644 --- a/src/cli/stop-approval.ts +++ b/src/cli/stop-approval.ts @@ -20,6 +20,17 @@ export interface GuardedStopSnapshot { manager: Exclude; } +/** + * What the tracked-record check can report: the verified target, null when the + * caller cannot name a cause (the pre-diagnostics shape), or the fixed cause of + * the single check that failed. The cause is a fixed sentence fragment from the + * inspection probe -- it carries no path, command line, stderr, or token. + */ +export type TrackedRuntimeCheck = + | { pid: number; port: number; hostname: string } + | { untracked: string } + | null; + function sameGuardedManager( expected: GuardedStopSnapshot["manager"], current: GuardedManagerTarget, @@ -117,7 +128,7 @@ export function managerStillActive( export async function runApprovedStop( expected: StopApproval, read: () => Promise, - tracked: () => { pid: number; port: number; hostname: string } | null, + tracked: () => TrackedRuntimeCheck, managerTarget: () => GuardedManagerTarget, stop: (snapshot: GuardedStopSnapshot) => Promise, ): Promise { @@ -130,6 +141,9 @@ export async function runApprovedStop( const mismatch = stopApprovalMismatch(expected, now); if (mismatch !== null) return approvalChanged(mismatch); if (target === null) return approvalChanged("the tracked runtime record could not be verified"); + if ("untracked" in target) { + return approvalChanged(`the tracked runtime record could not be verified (${target.untracked})`); + } if (target.pid !== expected.pid || target.port !== expected.port || target.hostname !== expected.hostname) { return approvalChanged("the tracked runtime record changed"); diff --git a/src/cli/v2.ts b/src/cli/v2.ts index 116cc1be0dc..734cccd3023 100644 --- a/src/cli/v2.ts +++ b/src/cli/v2.ts @@ -21,6 +21,7 @@ import type { ResolveDeps, SpawnInvocation } from "../lib/win-exec"; import { validatedCodexExecInvocation } from "../codex/exec-boundary"; import { deleteConfigTopLevelKey, loadConfig, saveConfig } from "../config"; import { resolveAndPersistCodexRuntime, type ResolveCodexRuntimeDeps } from "../codex/runtime"; +import { withCodexCliSelectionParentWriteAsync } from "../codex/cli-update-selection-gate"; export interface V2CliDeps { execFile?: (file: string, args: string[], options?: SpawnInvocation["options"] & { env?: NodeJS.ProcessEnv }) => void; @@ -79,6 +80,11 @@ export function codexFeaturesInvocation( }); } +export async function codexFeaturesInvocationAsync(action: "enable" | "disable", feature = "multi_agent_v2", + platform: NodeJS.Platform = process.platform, deps: CodexFeaturesInvocationDeps = {}): Promise { + return withCodexCliSelectionParentWriteAsync(() => codexFeaturesInvocation(action, feature, platform, deps), { configDir: deps.configDir }); +} + /** * Run `codex features ` synchronously - the management API * fallback when no deps toggle is injected. Shares the invocation builder and @@ -195,45 +201,77 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: () return emit(true, []); } if (parsed.verb === "mode-hint") { - const result = setMultiAgentModeHintText(parsed.value); + const result = parsed.value === null ? setMultiAgentModeHintText(null) + : await withCodexCliSelectionParentWriteAsync(() => setMultiAgentModeHintText(parsed.value)); if (!result.ok) return emit(false, ["Unable to write the native mode hint. Check Codex support and ownership, then read ocx v2 status."]); changed = result.changed; return emit(true, [result.changed ? "multi_agent_mode_hint_text saved — applies to new sessions." : "multi_agent_mode_hint_text already set — nothing to do."]); } if (parsed.verb === "threads") { - const enabled = (deps.isEnabled ?? isMultiAgentV2Enabled)(); - const result = transitionMultiAgentV2(enabled, (next, env, validate) => runCodexFeatures(next ? "enable" : "disable", deps, env, validate), { threadLimit: parsed.value }); + // Admission can wait on recovery ACL checks; the feature state is read once admitted, in + // the same synchronous turn as the transition, so a change made meanwhile is not undone. + let enabled = false; + const result = await withCodexCliSelectionParentWriteAsync(() => { + enabled = (deps.isEnabled ?? isMultiAgentV2Enabled)(); + return transitionMultiAgentV2(enabled, (next, env, validate) => runCodexFeatures(next ? "enable" : "disable", deps, env, validate), { threadLimit: parsed.value }); + }); if (!result.ok) return emit(false, ["Unable to update the native thread limit. Read ocx v2 status before retrying."]); changed = result.changed; return emit(true, [changed ? `max_threads = ${parsed.value} (${enabled ? "v2" : "v1"}) — applies to new sessions.` : `max_threads already ${parsed.value} — nothing to do.`]); } - const config = loadConfig(); + // Every awaited admission below re-reads, decides and saves the config inside its + // synchronous callback, so a setting changed while admission waited is neither + // overruled by a stale decision nor overwritten by a stale save. + let config = loadConfig(); let successLines: string[]; let unchangedKeep: boolean | undefined; if (parsed.verb === "mode") { - changed = (config.multiAgentMode ?? "default") !== parsed.value; + const persistMode = (): boolean => { + const before = config.multiAgentMode ?? "default"; + if (parsed.value === "default") deleteConfigTopLevelKey(config, "multiAgentMode"); + else config.multiAgentMode = parsed.value; + saveConfig(config); + return before !== parsed.value; + }; if (parsed.value !== "default") { - const target = parsed.value === "v2" && config.keepNativeChatGptOnV1 !== true; - const transition = transitionMultiAgentV2(target, (next, env, validate) => runCodexFeatures(next ? "enable" : "disable", deps, env, validate)); + let modeChanged = false; + const transition = await withCodexCliSelectionParentWriteAsync(() => { + config = loadConfig(); + const target = parsed.value === "v2" && config.keepNativeChatGptOnV1 !== true; + const result = transitionMultiAgentV2(target, (next, env, validate) => runCodexFeatures(next ? "enable" : "disable", deps, env, validate)); + if (result.ok) modeChanged = persistMode(); + return result; + }); if (!transition.ok) { changed = false; return emit(false, ["Native mode transition failed. Read ocx v2 status before retrying."]); } - changed ||= transition.changed; + changed = modeChanged || transition.changed; + } else { + changed = persistMode(); } - if (parsed.value === "default") deleteConfigTopLevelKey(config, "multiAgentMode"); - else config.multiAgentMode = parsed.value; - saveConfig(config); successLines = [multiAgentModeLine(parsed.value, config.keepNativeChatGptOnV1 === true), "Applies to NEW sessions; running sessions keep their pinned multi-agent version."]; } else if (parsed.verb === "keep-native-v1") { - unchangedKeep = (config.keepNativeChatGptOnV1 === true) === parsed.value; - changed = !unchangedKeep; + const persistKeep = (): void => { + unchangedKeep = (config.keepNativeChatGptOnV1 === true) === parsed.value; + if (parsed.value) config.keepNativeChatGptOnV1 = true; + else deleteConfigTopLevelKey(config, "keepNativeChatGptOnV1"); + saveConfig(config); + }; if (parsed.value && requiresGlobalV2Disabled(config.multiAgentMode, true)) { - const transition = transitionMultiAgentV2(false, (next, env, validate) => runCodexFeatures(next ? "enable" : "disable", deps, env, validate)); + const transition = await withCodexCliSelectionParentWriteAsync(() => { + config = loadConfig(); + // Revalidated once admitted: mode may have left v2 while admission waited. + const result = requiresGlobalV2Disabled(config.multiAgentMode, true) + ? transitionMultiAgentV2(false, (next, env, validate) => runCodexFeatures(next ? "enable" : "disable", deps, env, validate)) + : { ok: true as const, changed: false }; + if (result.ok) persistKeep(); + return result; + }); if (!transition.ok) { changed = false; return emit(false, ["Native hybrid-mode transition failed. Read ocx v2 status before retrying."]); } - changed ||= transition.changed; + changed = !unchangedKeep || transition.changed; + } else { + persistKeep(); + changed = !unchangedKeep; } - if (parsed.value) config.keepNativeChatGptOnV1 = true; - else deleteConfigTopLevelKey(config, "keepNativeChatGptOnV1"); - saveConfig(config); successLines = [parsed.value ? "keep_native_chatgpt_on_v1: ON — ChatGPT-native rows stay v1 when mode is v2 (new sessions)." : "keep_native_chatgpt_on_v1: OFF — ChatGPT-native rows follow v1/base/v2 (new sessions)."]; @@ -242,7 +280,19 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: () if (want && requiresGlobalV2Disabled(config.multiAgentMode, config.keepNativeChatGptOnV1 === true)) { return emit(false, ["v2 on: incompatible with keep-native-v1 while mode is v2 — Codex's global multi_agent_v2 overrides the native v1 catalog pin. Run 'ocx v2 keep-native-v1 off' first."]); } - const transition = transitionMultiAgentV2(want, (next, env, validate) => runCodexFeatures(next ? "enable" : "disable", deps, env, validate)); + let incompatible = false; + const transition = await withCodexCliSelectionParentWriteAsync(() => { + // Revalidated once admitted: keep-native-v1 or mode v2 may have been set meanwhile. + const current = loadConfig(); + if (want && requiresGlobalV2Disabled(current.multiAgentMode, current.keepNativeChatGptOnV1 === true)) { + incompatible = true; + return { ok: true as const, changed: false }; + } + return transitionMultiAgentV2(want, (next, env, validate) => runCodexFeatures(next ? "enable" : "disable", deps, env, validate)); + }); + if (incompatible) { + return emit(false, ["v2 on: incompatible with keep-native-v1 while mode is v2 — Codex's global multi_agent_v2 overrides the native v1 catalog pin. Run 'ocx v2 keep-native-v1 off' first."]); + } if (!transition.ok) return emit(false, ["Native feature transition failed. Read ocx v2 status before retrying."]); changed = transition.changed; if (!changed) return emit(true, [`multi_agent_v2 already ${want ? "ON" : "OFF"} — nothing to do.`]); diff --git a/src/client/machine-api.ts b/src/client/machine-api.ts index 5aeb11efc56..12997a8e6f5 100644 --- a/src/client/machine-api.ts +++ b/src/client/machine-api.ts @@ -1,5 +1,5 @@ import { journalOwner } from "../codex/journal"; -import { diagnoseCodexShim, installCodexShim, uninstallCodexShim } from "../codex/shim"; +import { diagnoseCodexShim, installCodexShimAsync, uninstallCodexShimAsync } from "../codex/shim"; import { readManagementJsonBody } from "../server/management/body"; import type { OcxClientConnectionConfig } from "../types"; import { disconnectClient, syncConnectedClient } from "./connect"; @@ -130,7 +130,7 @@ export async function handleMachineApi( return Response.json({ error: "action must be install, repair, or uninstall" }, { status: 400 }); } try { - const result = input.action === "uninstall" ? uninstallCodexShim() : installCodexShim(); + const result = input.action === "uninstall" ? await uninstallCodexShimAsync() : await installCodexShimAsync(); return Response.json({ success: true, action: input.action, result, shim: diagnoseCodexShim() }); } catch (error) { return Response.json({ success: false, error: error instanceof Error ? error.message : "shim action failed" }, { status: 409 }); diff --git a/src/codex/app-server-restart-service.ts b/src/codex/app-server-restart-service.ts index a84715351b1..da1776e12f1 100644 --- a/src/codex/app-server-restart-service.ts +++ b/src/codex/app-server-restart-service.ts @@ -37,8 +37,8 @@ import { getServerListenPort } from "../server/lifecycle"; import type { DesktopPickerState } from "./desktop-app/picker-freshness"; async function defaultRestartDesktopApp(): Promise { - const { restartCodexDesktopApp } = await import("./desktop-app-restart"); - const outcome = restartCodexDesktopApp({ allowHandoff: false }); + const { restartCodexDesktopAppAsync } = await import("./desktop-app-restart"); + const outcome = await restartCodexDesktopAppAsync({ allowHandoff: false }); return { attempted: outcome.attempted, stopped: outcome.stopped, diff --git a/src/codex/auth-api/login-flow.ts b/src/codex/auth-api/login-flow.ts index 8b4547b8cc5..6cddd171904 100644 --- a/src/codex/auth-api/login-flow.ts +++ b/src/codex/auth-api/login-flow.ts @@ -1,6 +1,6 @@ import { withCodexAccountLogLabel } from "../account-label"; import { getCodexAccountCredential, markCodexAccountValidated, readCodexAccountRecord, saveCodexAccountCredential, CodexCredentialRefreshLockTimeoutError, CodexCredentialRefreshBusyError, CodexCredentialRefreshStaleError } from "../account-store"; -import { clearAccountQuota, isCodexQuotaExhausted, isValidWhamHistoryObservation, parseUsageQuota, setAccountQuotaFromParsed } from "../quota"; +import { clearAccountQuota, isCodexQuotaExhausted, parseUsageQuota, poolWhamPolicyObservation, setAccountQuotaFromParsed } from "../quota"; import type { StoredAccountQuota, WhamUsageResponse } from "../quota"; import { ConfigMutationLockError, withConfigMutationLockSync } from "../../config"; import { appendDefaultCodexAccountNamespace, codexAccountPickerEnabled } from "../account-namespaces"; @@ -278,7 +278,7 @@ export async function handleCodexAuthLoginStart(req: Request, config: OcxConfig, email = data.email?.trim() || email; plan = nonEmptyPlan(data.plan_type) ?? plan; quota = parseUsageQuota(data); - policyQuota = isValidWhamHistoryObservation(data) ? quota : null; + policyQuota = poolWhamPolicyObservation(data, quota); } } catch { /* wham fetch is non-blocking */ } // Reauth must refresh the same ChatGPT identity already bound to this pool slot. diff --git a/src/codex/auth-api/pool-quota-probe.ts b/src/codex/auth-api/pool-quota-probe.ts index abd6b2d5fe8..583fc24b6c6 100644 --- a/src/codex/auth-api/pool-quota-probe.ts +++ b/src/codex/auth-api/pool-quota-probe.ts @@ -3,7 +3,7 @@ import { fetchCodexUsage } from "../quota-query-backoff"; import type { CodexUsageOwner } from "../quota-query-backoff"; import { capturePoolQuotaWriter, getValidCodexToken, isCodexAccountGenerationLive, forceRefreshCodexPoolToken, markCodexAccountValidated, markCodexAccountValidationFailed, readCodexAccountRecord, isTerminalCodexPoolRefreshFailure, CodexCredentialGenerationConflictError, CodexCredentialRefreshLockTimeoutError, CodexCredentialRefreshBusyError, CodexCredentialRefreshStaleError, TokenRefreshError } from "../account-store"; import type { PoolQuotaWriter } from "../quota-types"; -import { isValidWhamHistoryObservation, getAccountQuota, isCompleteCodexQuotaRecoverySnapshot, parseUsageQuota, setAccountQuotaFromParsed } from "../quota"; +import { isValidWhamHistoryObservation, getAccountQuota, isCompleteCodexQuotaRecoverySnapshot, parseUsageQuota, poolWhamPolicyObservation, setAccountQuotaFromParsed } from "../quota"; import type { StoredAccountQuota, WhamUsageResponse } from "../quota"; import type { ManualResetRefreshLineage } from "../routing"; import { clearAccountNeedsReauth, markAccountNeedsReauth } from "../account-runtime-state"; @@ -377,7 +377,7 @@ export async function commitPoolQuotaResponse( return { quota: null, needsReauth: false, credentialGeneration: generation }; } const validPolicyObservation = isValidWhamHistoryObservation(data); - setAccountQuotaFromParsed(accountId, quota, writerGeneration, undefined, validPolicyObservation ? quota : null, + setAccountQuotaFromParsed(accountId, quota, writerGeneration, undefined, poolWhamPolicyObservation(data, quota), ctx.poolWriter && validPolicyObservation ? { writer: ctx.poolWriter, observedAt, source: "wham", raw: quota } : undefined, true); return { diff --git a/src/codex/catalog/provider-models.ts b/src/codex/catalog/provider-models.ts index 04238755718..877e6b275b9 100644 --- a/src/codex/catalog/provider-models.ts +++ b/src/codex/catalog/provider-models.ts @@ -89,6 +89,7 @@ import { providerRedirectError, } from "../../lib/provider-outbound"; import { redactSecretString } from "../../lib/redact"; +import { invalidModelDiscoveryHint } from "../../providers/model-discovery-diagnostic"; import { extractProviderModelItems, isRegistryModelDiscoveryUrl, @@ -857,7 +858,7 @@ export async function fetchProviderModelsWithAuth( }; if (shouldLog) { console.warn( - `[opencodex] Provider model discovery for "${name}" ${diagnostic[extracted.reason]} [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`, + `[opencodex] Provider model discovery for "${name}" ${diagnostic[extracted.reason]} [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}]${extracted.reason === "invalid_shape" ? invalidModelDiscoveryHint("diagnostic" in extracted ? extracted.diagnostic : undefined) : "."}`, ); } return observed(models, "degraded"); diff --git a/src/codex/cli-update-job-store-windows.ts b/src/codex/cli-update-job-store-windows.ts index 9444bff57f2..d7b6e31b333 100644 --- a/src/codex/cli-update-job-store-windows.ts +++ b/src/codex/cli-update-job-store-windows.ts @@ -686,83 +686,155 @@ export interface WindowsCliRecoveryIo { withProofHandle(name: string, run: (handle: bigint) => T): T; write(name: string, pendingName: string, body: string, adoptPending?: boolean): void; } +export interface WindowsCliRecoveryAsyncIo { + readonly dir: string; + names(): string[]; + read(name: string): Promise; + write(name: string, pendingName: string, body: string, adoptPending?: boolean): Promise; +} +type RecoveryStoreStep = { kind: "acl"; entries: readonly WindowsAclEntry[] } + | { kind: "harden"; path: string; directory: boolean }; +type RecoveryStoreSteps = Generator; +function runRecoveryStoreSteps(steps: RecoveryStoreSteps): T { + let step = steps.next(); + while (!step.done) { + try { + const effect = step.value; + if (effect.kind === "acl") { + if (!verifyWindowsEntriesAcl(aclTargets(effect.entries), ACL_VERIFY_TIMEOUT_MS)) throw unsafe(); + } else if (effect.directory) hardenSecretDir(toNamespacedPath(effect.path), { required: true }); + else hardenSecretPath(toNamespacedPath(effect.path), { required: true }); + } catch (error) { step = steps.throw(error); continue; } + step = steps.next(); + } + return step.value; +} +async function runRecoveryStoreStepsAsync(steps: RecoveryStoreSteps): Promise { + let step = steps.next(); + while (!step.done) { + try { + const effect = step.value; + if (effect.kind === "acl") await verifyAcl(effect.entries); + else if (effect.directory) await hardenSecretDirAsync(toNamespacedPath(effect.path), { required: true }); + else await hardenSecretPathAsync(toNamespacedPath(effect.path), { required: true }); + } catch (error) { step = steps.throw(error); continue; } + step = steps.next(); + } + return step.value; +} +function openRecoveryContext(config: string): Ctx { + if (process.platform !== "win32" || process.arch !== "x64") throw unsafe(); + assertNotRealHomeUnderTest(config); + const kernel = recoveryFfi.dlopen("kernel32.dll", KERNEL_SYMBOLS); + try { return { ffi: recoveryFfi, kernel, nt: recoveryFfi.dlopen("ntdll.dll", NT_SYMBOLS), pins: new PinSet() }; } + catch (error) { kernel.close(); throw error; } +} /** The shared recovery protocol uses the ledger's native ancestry, no-reparse, * identity, private-DACL and rename primitives at synchronous gate boundaries. * Restart publication may write an existing pinned store without creating it. */ export function withWindowsCliRecoveryStore(config: string, create: boolean, run: (io: WindowsCliRecoveryIo) => T, publish = false): T { - if (process.platform !== "win32" || process.arch !== "x64") throw unsafe(); - assertNotRealHomeUnderTest(config); - const kernel = recoveryFfi.dlopen("kernel32.dll", KERNEL_SYMBOLS); - const nt = recoveryFfi.dlopen("ntdll.dll", NT_SYMBOLS); - const ctx: Ctx = { ffi: recoveryFfi, kernel, nt, pins: new PinSet() }; - const verify = (entries: readonly WindowsAclEntry[]) => { - if (!verifyWindowsEntriesAcl(aclTargets(entries), ACL_VERIFY_TIMEOUT_MS)) throw unsafe(); - }; + const ctx = openRecoveryContext(config); try { + const io = runRecoveryStoreSteps(recoveryStoreSteps(ctx, config, create, publish)); + return run({ dir: io.dir, names: io.names, + read: name => runRecoveryStoreSteps(io.read(name)), + withProofHandle: (name, use) => runRecoveryStoreSteps(io.withProofHandle(name, use)), + write: (name, pending, body, adopt) => runRecoveryStoreSteps(io.write(name, pending, body, adopt)), + }); + } finally { ctx.pins.closeAll(ctx.kernel); ctx.nt.close(); ctx.kernel.close(); } +} + +/** Parent admission awaits the same native protocol; pins outlive every ACL read. */ +export async function withWindowsCliRecoveryStoreAsync(config: string, create: boolean, + run: (io: WindowsCliRecoveryAsyncIo) => Promise, publish = false): Promise { + const ctx = openRecoveryContext(config); + try { + const io = await runRecoveryStoreStepsAsync(recoveryStoreSteps(ctx, config, create, publish)); + return await run({ dir: io.dir, names: io.names, + read: name => runRecoveryStoreStepsAsync(io.read(name)), + write: (name, pending, body, adopt) => runRecoveryStoreStepsAsync(io.write(name, pending, body, adopt)), + }); + } finally { ctx.pins.closeAll(ctx.kernel); ctx.nt.close(); ctx.kernel.close(); } +} + +function* recoveryStoreSteps(ctx: Ctx, config: string, create: boolean, publish: boolean) { + const kernel = ctx.kernel; const chain = pinChain(ctx, config, ACCESS_DIR_PIN); - verify(chain.map((entry, i) => ({ path: entry.path, directory: true, - policy: i >= chain.length - 2 ? "write-private" : "trusted-ancestry" }))); + const checkChain = () => { + for (const entry of chain) if (!sameIdentity(entry.inspection, inspect(ctx, entry.handle, true))) throw unsafe(); + }; + yield { kind: "acl", entries: chain.map((entry, i) => ({ path: entry.path, directory: true, + policy: i >= chain.length - 2 ? "write-private" : "trusted-ancestry" })) } satisfies RecoveryStoreStep; + checkChain(); const configPin = chain.at(-1); if (!configPin) throw unsafe(); const dir = join(config, "codex-cli-update-recovery"); try { - if (create) { mkdirSync(dir); hardenSecretDir(toNamespacedPath(dir), { required: true }); } + if (create) { mkdirSync(dir); yield { kind: "harden", path: dir, directory: true } satisfies RecoveryStoreStep; } } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; } const directoryPin = relOpen(ctx, configPin.handle, "codex-cli-update-recovery", true, create || publish ? ACCESS_DIR_PIN : ACCESS_READ, SHARE_DIR); - inspect(ctx, directoryPin, true); assertCanonicalPath(ctx, directoryPin, dir); - verify([{ path: dir, directory: true, policy: "private" }]); + const directoryIdentity = inspect(ctx, directoryPin, true); assertCanonicalPath(ctx, directoryPin, dir); + const checkDirectory = () => { + checkChain(); + if (!sameIdentity(directoryIdentity, inspect(ctx, directoryPin, true))) throw unsafe(); + }; + yield { kind: "acl", entries: [{ path: dir, directory: true, policy: "private" }] } satisfies RecoveryStoreStep; + checkDirectory(); if (create) flushDir(ctx, configPin.handle); - const leaf = (name: string, access = ACCESS_READ, sharing = SHARE_LEAF_VERIFY) => { + const leaf = function* (name: string, access = ACCESS_READ, sharing = SHARE_LEAF_VERIFY): RecoveryStoreSteps<{ handle: bigint; stat: StoreInspection }> { if (!/^[0-9a-f-]{36}(?:\.(?:terminal|proof))?(?:\.pending|\.json|\.terminal|\.proof)$/.test(name)) throw unsafe(); const handle = relOpen(ctx, directoryPin, name, false, access, sharing); const stat = inspect(ctx, handle, false); if (stat.links !== 1 || stat.size > 1024) throw unsafe(); - verify([{ path: join(dir, name), directory: false, policy: "private" }]); + yield { kind: "acl", entries: [{ path: join(dir, name), directory: false, policy: "private" }] }; + checkDirectory(); + const current = inspect(ctx, handle, false); + if (!sameIdentity(stat, current) || current.links !== 1 || current.size !== stat.size) throw unsafe(); return { handle, stat }; }; - return run({ dir, + return { dir, names: () => readdirSync(dir), - read(name) { - const file = leaf(name); + *read(name: string): RecoveryStoreSteps { + const file = yield* leaf(name); try { return readAll(ctx, file.handle, file.stat.size).toString("utf8"); } finally { ctx.pins.release(file.handle, kernel); } }, - withProofHandle(name, use) { + *withProofHandle(name: string, use: (handle: bigint) => T): RecoveryStoreSteps { if (!/^[0-9a-f-]{36}\.proof$/.test(name)) throw unsafe(); - const file = leaf(name, 0x40100080, 7); + const file = yield* leaf(name, 0x40100080, 7); try { return use(file.handle); } finally { ctx.pins.release(file.handle, kernel); } }, - write(name, pendingName, body, adoptPending = false) { + *write(name: string, pendingName: string, body: string, adoptPending = false): RecoveryStoreSteps { if (!(create || publish) || Buffer.byteLength(body) > 1024 || !/^[0-9a-f-]{36}(?:\.terminal|\.proof)?$/.test(name.replace(/\.json$/, "")) || pendingName !== name.replace(/\.json$/, "") + ".pending") throw unsafe(); // Recovery preserves the only durable child identity until atomic rename. const temp = join(dir, pendingName), destination = join(dir, name); if (!adoptPending) { try { - const old = leaf(pendingName, ACCESS_LEAF, SHARE_LEAF); + const old = yield* leaf(pendingName, ACCESS_LEAF, SHARE_LEAF); markDelete(ctx, old.handle); ctx.pins.release(old.handle, kernel); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } const fd = openSync(temp, "wx", 0o600); try { writeFileSync(fd, body); fsyncSync(fd); } finally { closeSync(fd); } } - hardenSecretPath(toNamespacedPath(temp), { required: true }); - const file = leaf(pendingName, adoptPending ? ACCESS_LEAF | 0x40000000 : ACCESS_LEAF, SHARE_LEAF); + yield { kind: "harden", path: temp, directory: false }; + checkDirectory(); + const file = yield* leaf(pendingName, adoptPending ? ACCESS_LEAF | 0x40000000 : ACCESS_LEAF, SHARE_LEAF); try { if (!readAll(ctx, file.handle, file.stat.size).equals(Buffer.from(body))) throw unsafe(); if (adoptPending) flushDir(ctx, file.handle); renamePinned(ctx, file.handle, destination); // The held adopted pin denies other writers; this reader must share // its write/flush access as well as DELETE through identity readback. - const published = leaf(name, ACCESS_READ, adoptPending ? SHARE_LEAF_VERIFY | 0x2 : SHARE_LEAF_VERIFY); + const published = yield* leaf(name, ACCESS_READ, adoptPending ? SHARE_LEAF_VERIFY | 0x2 : SHARE_LEAF_VERIFY); try { if (!sameIdentity(file.stat, published.stat)) throw unsafe(); } finally { ctx.pins.release(published.handle, kernel); } flushDir(ctx, directoryPin); } finally { ctx.pins.release(file.handle, kernel); forgetEphemeralSecretPath(toNamespacedPath(temp)); } }, - }); - } finally { ctx.pins.closeAll(kernel); nt.close(); kernel.close(); } + }; } export interface WindowsJobStoreIo { diff --git a/src/codex/cli-update-job-worker.ts b/src/codex/cli-update-job-worker.ts index 0dc08e8b2b0..c61aa759523 100644 --- a/src/codex/cli-update-job-worker.ts +++ b/src/codex/cli-update-job-worker.ts @@ -34,9 +34,7 @@ import { trustedNodeLauncherContext } from "../cli/launcher-context"; import { spawnWorker } from "../lib/worker-embed"; import { recoverClosedCodexCliUpdateWorker } from "./cli-update-worker-recovery"; import { - CODEX_CLI_UPDATE_GATE_FREE, - CODEX_CLI_UPDATE_GATE_PARENT_WRITE, - CODEX_CLI_UPDATE_GATE_WORKER_COMMIT, + codexCliSelectionGateState, cancelCodexCliUpdateFence, codexCliUpdateSelectionGate, createCodexCliUpdateFence, @@ -108,13 +106,8 @@ export interface CodexCliUpdateWorkerDiagnostics { } export function codexCliUpdateWorkerDiagnostics(): CodexCliUpdateWorkerDiagnostics { - const raw = Atomics.load(new Int32Array(codexCliUpdateSelectionGate()), 0); - const gate = raw === CODEX_CLI_UPDATE_GATE_FREE ? "free" - : raw === CODEX_CLI_UPDATE_GATE_PARENT_WRITE ? "parent_write" - : raw >= CODEX_CLI_UPDATE_GATE_WORKER_COMMIT ? "worker_commit" - : "unrecognized"; return { - gate, + gate: codexCliSelectionGateState(), liveJobs: [...liveJobs.entries()].map(([jobId, record]) => ({ jobId, settled: record.settled(), workerClosed: record.workerClosed(), })), @@ -188,8 +181,7 @@ function trackUnjoined(worker: Worker, alreadyClosed: boolean): void { * recovery releases admission only after matching terminal proof. */ function warnIfCommitGateHeld(): void { - if (Atomics.load(new Int32Array(codexCliUpdateSelectionGate()), 0) - < CODEX_CLI_UPDATE_GATE_WORKER_COMMIT) return; + if (codexCliSelectionGateState() !== "worker_commit") return; console.warn( "[opencodex] a Codex CLI update worker closed while the shared selection gate still reads" + " worker_commit; admission awaits owner-scoped terminal evidence — inspect" diff --git a/src/codex/cli-update-recovery.ts b/src/codex/cli-update-recovery.ts index c605a31cb66..5f240a90682 100644 --- a/src/codex/cli-update-recovery.ts +++ b/src/codex/cli-update-recovery.ts @@ -3,7 +3,7 @@ import { closeSync, constants, fstatSync, fsyncSync, ftruncateSync, lstatSync, m import { dirname, join } from "node:path"; import { getConfigDir } from "../config/paths"; import { assertTrustedCodexCliAncestry } from "./cli-update-paths"; -import { withWindowsCliRecoveryStore } from "./cli-update-job-store-windows"; +import { withWindowsCliRecoveryStore, withWindowsCliRecoveryStoreAsync } from "./cli-update-job-store-windows"; import { windowsCodexCliChildGone } from "./windows-cli-recovery"; export interface CodexCliUpdateNativeChildIdentity { jobName: string; creationTime: string } @@ -169,19 +169,64 @@ export function codexCliUpdateRecoveryBlocksAdmission(configDir = getConfigDir() } catch { return true; } // Existing evidence that disappears/is unreadable stays fenced. } +/** Every attempt observes fresh records; this never caches an ACL/admission verdict. */ +export async function codexCliUpdateRecoveryBlocksAdmissionAsync(configDir = getConfigDir()): Promise { + if (process.platform !== "win32") return codexCliUpdateRecoveryBlocksAdmission(configDir); + try { lstatSync(join(configDir, "codex-cli-update-recovery")); } + catch (error) { return (error as NodeJS.ErrnoException).code !== "ENOENT"; } + try { + const canonical = realpathSync.native(configDir); + return await withWindowsCliRecoveryStoreAsync(canonical, false, async io => { + const steps = inspectRecoveryFilesSteps(io.names(), true); + let step = steps.next(); + while (!step.done) { + const effect = step.value; + if (effect.kind === "publish") { + await io.write(effect.record.token + ".json", effect.record.token + ".pending", JSON.stringify(effect.record), true); + step = steps.next(undefined); + } else { + const record = JSON.parse(await io.read(effect.token + (effect.suffix ?? ".json"))) as RecoveryRecord; + if (!validRecord(record, effect.token)) throw new Error("invalid_recovery_owner"); + step = steps.next(record); + } + } + return step.value; + }, true); + } catch { return true; } +} + +type RecoveryInspectionStep = { kind: "read"; token: string; suffix?: string } + | { kind: "publish"; record: RecoveryRecord }; +type RecoveryInspectionSteps = Generator; +function* childGoneSteps(record: RecoveryRecord): RecoveryInspectionSteps { + if (process.platform !== "win32") return childGone(record); + if (record.pgid === null || !record.windows?.jobName || !record.windows.creationTime) return false; + const proof = (yield { kind: "read", token: record.token, suffix: ".proof" })!; + return childGone(record, () => proof); +} function inspectRecoveryFiles(files: string[], readRecord: (token: string, suffix?: string) => RecoveryRecord, publishChild?: (record: RecoveryRecord) => void): boolean { + const steps = inspectRecoveryFilesSteps(files, !!publishChild); + let step = steps.next(); + while (!step.done) { + const effect = step.value; + if (effect.kind === "publish") { publishChild!(effect.record); step = steps.next(undefined); } + else step = steps.next(readRecord(effect.token, effect.suffix)); + } + return step.value; +} +function* inspectRecoveryFilesSteps(files: string[], publishChild: boolean): RecoveryInspectionSteps { if (new Set(files.map(file => file.slice(0, 36))).size > 256) return true; const terminal = new Set(); const active = new Map(); for (const file of files.filter(file => file.endsWith(".terminal"))) { - const record = readRecord(file.slice(0, -9), ".terminal"); + const record = (yield { kind: "read", token: file.slice(0, -9), suffix: ".terminal" })!; if (record.phase !== "terminal") return true; terminal.add(record.token); } for (const file of files.filter(file => file.endsWith(".json"))) { if (terminal.has(file.slice(0, -5))) continue; - const record = readRecord(file.slice(0, -5)); + const record = (yield { kind: "read", token: file.slice(0, -5) })!; if (record.phase === "terminal") { terminal.add(record.token); continue; } active.set(record.token, record); } @@ -202,7 +247,7 @@ function inspectRecoveryFiles(files: string[], readRecord: (token: string, suffi if (terminal.has(token)) continue; // Published terminal authority shadows late writes. const suffix = file.slice(token.length); if (suffix !== ".pending" && suffix !== ".terminal.pending") return true; - const record = readRecord(token, suffix); + const record = (yield { kind: "read", token, suffix })!; const previous = active.get(token) ?? reconciled.get(token); if (process.platform === "win32" && suffix === ".pending" && record.phase === "child") { const armed = active.get(token); @@ -211,8 +256,8 @@ function inspectRecoveryFiles(files: string[], readRecord: (token: string, suffi || armed.windows.creationTime !== null || armed.windows.jobName !== record.windows?.jobName || record.pgid === null || record.pgid > 0xffffffff || !record.windows?.creationTime || BigInt(record.windows.creationTime) > 0xffffffffffffffffn - || !processGone(record.parentPid) || !childGone(record, token => readRecord(token, ".proof"))) return true; - publishChild(record); // Existing protected flush/ACL/rename path; any failure fences. + || !processGone(record.parentPid) || !(yield* childGoneSteps(record)) || !processGone(record.parentPid)) return true; + yield { kind: "publish", record }; // Existing protected flush/ACL/rename path; any failure fences. reconciled.set(token, record); continue; } // A complete cleanup write can survive a crash before rename. Recognize @@ -221,7 +266,8 @@ function inspectRecoveryFiles(files: string[], readRecord: (token: string, suffi if (record.phase !== "terminal" || record.pgid !== null || !processGone(record.parentPid) || (previous && previous.parentPid !== record.parentPid)) return true; if (previous?.phase === "child" - && !childGone(previous, token => readRecord(token, ".proof"))) return true; + && !(yield* childGoneSteps(previous))) return true; + if (!processGone(record.parentPid)) return true; reconciled.set(token, record); } for (const record of active.values()) { @@ -229,7 +275,7 @@ function inspectRecoveryFiles(files: string[], readRecord: (token: string, suffi // Re-probe on every admission attempt, including after a restart. A live // or inaccessible group stays fenced until the whole group proves absent. if (record.phase === "child" && record.pgid !== null - && processGone(record.parentPid) && childGone(record, token => readRecord(token, ".proof"))) continue; + && processGone(record.parentPid) && (yield* childGoneSteps(record)) && processGone(record.parentPid)) continue; return true; } return false; diff --git a/src/codex/cli-update-selection-gate.ts b/src/codex/cli-update-selection-gate.ts index 63e799a4d4f..a17c933728a 100644 --- a/src/codex/cli-update-selection-gate.ts +++ b/src/codex/cli-update-selection-gate.ts @@ -30,8 +30,10 @@ * stays the documented residual it always was. */ -import { codexCliUpdateRecoveryBlocksAdmission } from "./cli-update-recovery"; +import { codexCliUpdateRecoveryBlocksAdmission, codexCliUpdateRecoveryBlocksAdmissionAsync } from "./cli-update-recovery"; import type { CodexCliUpdateNativeChildIdentity, CodexCliUpdateNativeRecoveryProof } from "./cli-update-recovery"; +import { getConfigDir } from "../config/paths"; +import { resolve } from "node:path"; export const CODEX_CLI_UPDATE_FENCE_PREPARING = 0; export const CODEX_CLI_UPDATE_FENCE_CANCELLED = 1; @@ -53,6 +55,10 @@ export class CodexCliSelectionMutationBusyError extends Error { } let selectionGate: SharedArrayBuffer | null = null; +let asyncParentOwner: { buffer: SharedArrayBuffer; owner: number } | null = null; +// This scope exists only during one synchronous commit turn. It is never kept +// across an await, so unrelated parent requests cannot borrow its admission. +let parentCommitScope: { buffer: SharedArrayBuffer; owner: number; configDir: string } | null = null; /** One process-wide holder and monotonic generation counter shared across realms. */ export function codexCliUpdateSelectionGate(): SharedArrayBuffer { @@ -60,9 +66,19 @@ export function codexCliUpdateSelectionGate(): SharedArrayBuffer { return selectionGate; } +/** Diagnostic classification does not grant or release gate ownership. */ +export function codexCliSelectionGateState(): "free" | "parent_write" | "worker_commit" | "unrecognized" { + const buffer = codexCliUpdateSelectionGate(), owner = Atomics.load(new Int32Array(buffer), 0); + if (owner === CODEX_CLI_UPDATE_GATE_FREE) return "free"; + if (owner === CODEX_CLI_UPDATE_GATE_PARENT_WRITE + || (asyncParentOwner?.buffer === buffer && asyncParentOwner.owner === owner)) return "parent_write"; + return owner >= CODEX_CLI_UPDATE_GATE_WORKER_COMMIT ? "worker_commit" : "unrecognized"; +} + /** Test seam: drop the cell so each case starts from FREE on a fresh buffer. */ export function resetCodexCliSelectionGatesForTests(): void { selectionGate = null; + asyncParentOwner = null; } /** Per-job fence: decision, current child pgid (0 = unpublished), terminal proof. */ @@ -112,8 +128,13 @@ export interface CodexCliSelectionParentWrite { * paths. entered === false means a commit window (or another writer) is in * flight and the caller must surface busy immediately, never wait. */ -export function beginCodexCliSelectionParentWrite(): CodexCliSelectionParentWrite { - if (codexCliUpdateRecoveryBlocksAdmission()) return Object.freeze({ entered: false, exit() {} }); +export function beginCodexCliSelectionParentWrite(configDir = getConfigDir()): CodexCliSelectionParentWrite { + if (parentCommitScope) { + const entered = resolve(configDir) === parentCommitScope.configDir + && Atomics.load(new Int32Array(parentCommitScope.buffer), 0) === parentCommitScope.owner; + return Object.freeze({ entered, exit() {} }); + } + if (codexCliUpdateRecoveryBlocksAdmission(configDir)) return Object.freeze({ entered: false, exit() {} }); const view = new Int32Array(codexCliUpdateSelectionGate()); if (Atomics.compareExchange(view, 0, CODEX_CLI_UPDATE_GATE_FREE, CODEX_CLI_UPDATE_GATE_PARENT_WRITE) !== CODEX_CLI_UPDATE_GATE_FREE) { @@ -125,11 +146,60 @@ export function beginCodexCliSelectionParentWrite(): CodexCliSelectionParentWrit exit() { if (!held) return; held = false; - Atomics.store(view, 0, CODEX_CLI_UPDATE_GATE_FREE); + Atomics.compareExchange(view, 0, CODEX_CLI_UPDATE_GATE_PARENT_WRITE, CODEX_CLI_UPDATE_GATE_FREE); + }, + }); +} + +type SynchronousParentCommit = () => T & (T extends PromiseLike ? never : unknown); +export interface CodexCliSelectionAsyncParentWrite extends CodexCliSelectionParentWrite { + /** Run existing synchronous writers under this exact fresh admission. Never await inside. */ + commit(run: SynchronousParentCommit): T; +} +interface ParentAdmissionOptions { + configDir?: string; + signal?: AbortSignal; + /** Test seam; production always uses the fresh async recovery protocol. */ + recoveryBlocksAdmission?: (configDir?: string) => Promise; +} +/** Reserve before inspection, keep the generation across awaits, and release only that owner. */ +export async function beginCodexCliSelectionParentWriteAsync(options: ParentAdmissionOptions = {}): Promise { + const refused = Object.freeze({ entered: false, exit() {}, commit(_run: SynchronousParentCommit): T { throw new CodexCliSelectionMutationBusyError(); } }); + if (options.signal?.aborted) return refused; + const configDir = resolve(options.configDir ?? getConfigDir()); + const buffer = codexCliUpdateSelectionGate(), view = new Int32Array(buffer); + const owner = allocateCodexCliUpdateGateOwner(buffer); + if (Atomics.compareExchange(view, 0, CODEX_CLI_UPDATE_GATE_FREE, owner) !== CODEX_CLI_UPDATE_GATE_FREE) return refused; + const parent = { buffer, owner }; + asyncParentOwner = parent; + let held = true; + const exit = () => { + if (!held) return; + held = false; + releaseCodexCliUpdateGateOwner(buffer, owner); + if (asyncParentOwner === parent) asyncParentOwner = null; + }; + try { + if (await (options.recoveryBlocksAdmission ?? codexCliUpdateRecoveryBlocksAdmissionAsync)(configDir) + || options.signal?.aborted || Atomics.load(view, 0) !== owner) { exit(); return refused; } + } catch { exit(); return refused; } + return Object.freeze({ entered: true, exit, + commit(run: SynchronousParentCommit): T { + if (!held || options.signal?.aborted || Atomics.load(view, 0) !== owner) throw new CodexCliSelectionMutationBusyError(); + const previous = parentCommitScope; + parentCommitScope = { buffer, owner, configDir }; + try { return run(); } finally { parentCommitScope = previous; } }, }); } +/** The callback is synchronous: config critical sections retain their original no-await contract. */ +export async function withCodexCliSelectionParentWriteAsync(run: SynchronousParentCommit, options: ParentAdmissionOptions = {}): Promise { + const admission = await beginCodexCliSelectionParentWriteAsync(options); + if (!admission.entered) throw new CodexCliSelectionMutationBusyError(); + try { return admission.commit(run); } finally { admission.exit(); } +} + /** * The worker's installation window. tryEnter covers the uncontended same-turn * case; waitEnter is the bounded retry for a held parent write. commit() is diff --git a/src/codex/desktop-app-restart.ts b/src/codex/desktop-app-restart.ts index 638be90b91e..e6909c7420f 100644 --- a/src/codex/desktop-app-restart.ts +++ b/src/codex/desktop-app-restart.ts @@ -41,7 +41,7 @@ import { darwinDesktopAppAdapter, darwinDefaultExec } from "./desktop-app/darwin import { linuxDesktopAppAdapter, linuxDefaultExec } from "./desktop-app/linux"; import { windowsDesktopAppAdapter, windowsDefaultExec } from "./desktop-app/windows"; import { isTestHomeGuardArmed } from "../lib/test-home-guard"; -import { beginCodexCliSelectionParentWrite } from "./cli-update-selection-gate"; +import { beginCodexCliSelectionParentWrite, withCodexCliSelectionParentWriteAsync, CodexCliSelectionMutationBusyError } from "./cli-update-selection-gate"; export type { DesktopAppExecOptions } from "./desktop-app/types"; @@ -388,3 +388,13 @@ export function restartCodexDesktopApp(io: DesktopAppRestartIo = {}): DesktopApp if (lockHeld && !handedOff) releaseDesktopRestartLock(io.lock); } } + +export async function restartCodexDesktopAppAsync(io: DesktopAppRestartIo = {}): Promise { + try { return await withCodexCliSelectionParentWriteAsync(() => restartCodexDesktopApp(io)); } + catch (error) { + if (error instanceof CodexCliSelectionMutationBusyError) return { + attempted: false, stopped: [], surviving: [], relaunch: "skipped", reason: "selection_mutation_busy", + }; + throw error; + } +} diff --git a/src/codex/desktop-app/handoff.ts b/src/codex/desktop-app/handoff.ts index 3baf6d83b43..121b220d226 100644 --- a/src/codex/desktop-app/handoff.ts +++ b/src/codex/desktop-app/handoff.ts @@ -284,7 +284,7 @@ export async function runDesktopRestartHandoff( try { const restart = io.restart ? io.restart(false) - : (await import("../desktop-app-restart")).restartCodexDesktopApp({ + : await (await import("../desktop-app-restart")).restartCodexDesktopAppAsync({ allowHandoff: false, lock: io.lock, }); diff --git a/src/codex/exec-boundary.ts b/src/codex/exec-boundary.ts index 26f869fdf27..9db1b9291e8 100644 --- a/src/codex/exec-boundary.ts +++ b/src/codex/exec-boundary.ts @@ -20,8 +20,9 @@ import { * known-Codex-bundle classifier, and — when the lexical or canonical path sits * inside a ChatGPT.app or Codex.app bundle at any location — checked by the shared * signature/ownership policy before the same canonical target is returned for - * execution. Anything else, including binaries inside third-party app bundles, - * is passed through to the shared launcher unchanged. + * execution. CLI handoffs additionally classify any enclosing lexical or canonical .app, + * independent of bundle name or identifier. Other callers retain the known-app + * policy; non-bundle executables pass through to the shared launcher unchanged. * * The check is one-shot per invocation and deliberately not memoized: * directory-service group membership, signature state, and directory-entry @@ -53,6 +54,10 @@ export interface CodexExecBoundaryDeps { executable?: (path: string) => boolean; /** Canonicalizer; returns null when the path cannot be resolved. */ realpath?: (path: string) => string | null; + /** CLI handoffs verify any enclosing app, without trusting mutable bundle identity metadata. */ + verifyAnyAppBundle?: boolean; + /** Discovered Desktop bundle identity, retained by its generated launcher across respawns. */ + expectedBundleRoot?: string; macBundleTrustReason?: (bundleRoot: string, binary: string) => string | null; } @@ -113,22 +118,36 @@ function resolveDarwinExecPath(command: string, deps: CodexExecBoundaryDeps): { /** * Canonical (bundleRoot, binary) pair when the resolved path classifies into a - * known Codex desktop bundle, else null. The pair is always consistent: + * known Codex desktop bundle (or any enclosing app for CLI handoffs), else null. The pair is always consistent: * both halves are canonicalized together so a symlinked HOME or a PATH alias * cannot split the containment check, and a bundle member that cannot be * canonicalized refuses rather than escaping the known-bundle classification. */ function darwinBundleTarget(resolved: string, deps: CodexExecBoundaryDeps): { bundle: string; binary: string } | null { const realpath = deps.realpath ?? realpathOrNull; - const bundle = macCodexAppBundleFor(resolved, { env: deps.env, platform: deps.platform, realpath }); - if (!bundle) return null; + if (deps.expectedBundleRoot !== undefined && !posix.isAbsolute(deps.expectedBundleRoot)) { + throw new CodexExecTrustError("the recorded app bundle must be an absolute path"); + } const binary = realpath(resolved); + const enclosingApp = (path: string) => /^(.*?\/[^/]*\.app)(?:\/|$)/i.exec(path)?.[1]; + const lexicalBundle = deps.verifyAnyAppBundle && deps.expectedBundleRoot === undefined ? enclosingApp(resolved) : undefined; + // Verify the outer canonical app for nested layouts, but retain lexical containment. + const enclosing = deps.verifyAnyAppBundle && binary !== null ? enclosingApp(binary) : undefined; + const bundle = deps.expectedBundleRoot ?? enclosing ?? lexicalBundle ?? macCodexAppBundleFor(resolved, { env: deps.env, platform: deps.platform, realpath }); + if (!bundle) return null; if (binary === null) throw new CodexExecTrustError("cannot canonicalize the app-bundle exec target"); const canonicalBundle = realpath(bundle); if (canonicalBundle === null) throw new CodexExecTrustError("cannot canonicalize the app bundle"); if (!binary.startsWith(canonicalBundle + "/Contents/")) { throw new CodexExecTrustError("the app-bundle exec target is outside bundle Contents"); } + if (lexicalBundle && lexicalBundle !== bundle) { + const canonicalLexicalBundle = realpath(lexicalBundle); + if (canonicalLexicalBundle === null) throw new CodexExecTrustError("cannot canonicalize the app bundle"); + if (!binary.startsWith(canonicalLexicalBundle + "/Contents/")) { + throw new CodexExecTrustError("the app-bundle exec target is outside bundle Contents"); + } + } return { bundle: canonicalBundle, binary }; } @@ -144,6 +163,7 @@ export function validatedCodexExecTarget( deps: CodexExecBoundaryDeps = {}, ): ValidatedCodexExecTarget { if ((deps.platform ?? process.platform) !== "darwin") { + if (deps.expectedBundleRoot !== undefined) throw new CodexExecTrustError("recorded app bundles require macOS verification"); return { invocation: codexExecInvocation(command, args, deps.platform ?? process.platform, { env: deps.env, exists: deps.exists }), macAppBundle: false }; } const resolved = resolveDarwinExecPath(command, deps); @@ -164,6 +184,7 @@ export async function validatedCodexExecTargetAsync( ): Promise { deps.signal?.throwIfAborted(); if ((deps.platform ?? process.platform) !== "darwin") { + if (deps.expectedBundleRoot !== undefined) throw new CodexExecTrustError("recorded app bundles require macOS verification"); return { invocation: codexExecInvocation(command, args, deps.platform ?? process.platform, { env: deps.env, exists: deps.exists }), macAppBundle: false }; } const resolved = resolveDarwinExecPath(command, deps); diff --git a/src/codex/quota-types.ts b/src/codex/quota-types.ts index be1a0bb65c4..8a77c6d44fd 100644 --- a/src/codex/quota-types.ts +++ b/src/codex/quota-types.ts @@ -62,6 +62,11 @@ export function resetAtToMs(resetAt: number): number { return resetAt < RESET_AT_SECONDS_MAX ? resetAt * 1000 : resetAt; } +/** Only a valid reset at or before now proves a short-window reading has elapsed. */ +export function hasElapsedShortWindow(resetAt: number | undefined, now: number): boolean { + return typeof resetAt === "number" && Number.isFinite(resetAt) && resetAt > 0 && resetAtToMs(resetAt) <= now; +} + /** * A short-only reading that proves the account is blocked NOW. * diff --git a/src/codex/quota.ts b/src/codex/quota.ts index fe2bbbc10ba..b0933c46231 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -5,6 +5,7 @@ import { forgetMainAccountUsage, observeMainAccountUsage, type FreshWindow } fro import { closeSync, constants as fsConstants, existsSync, fstatSync, openSync, readSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { atomicWriteFile, getConfigDir } from "../config"; +import { withConfigDirHardeningSettled } from "../config/paths"; import { captureConfigGeneration, type GenerationContext } from "../lib/state-store-sweeper"; import { isThirtyDayOnlyCodexPlan } from "./plan"; import { stampCodexQuotaUsageObservation } from "./quota-observation-freshness"; @@ -42,6 +43,12 @@ let mainPolicyQuota: MainPolicyQuota | null = null; let diskHydrated = false; let persistTimer: ReturnType | null = null; let persistStartedAt: number | null = null; +/** The latest admitted snapshot write; settles even when admission times out. */ +let persistWrite: Promise = Promise.resolve(); +/** Bumped by a full clear so a fired callback still waiting for ACL admission writes nothing. */ +let persistGeneration = 0; +/** Test seam: wait for a fired persistence callback's admitted write. */ +export function settleQuotaPersistenceForTests(): Promise { return persistWrite; } const MONTHLY_WINDOW_MIN_SECONDS = 28 * 24 * 60 * 60; /** @@ -333,7 +340,10 @@ export function setAccountQuotaFromParsed( const mainDecisionGeneration = mainWriter && mainDecisionQuotaWriterGeneration(mainWriter); if (isMain && mainDecisionGeneration !== undefined && decisionRaw) publishCodexDecisionQuota(accountId, mainDecisionGeneration, decisionRaw, updatedAt, "codex-main", decisionObservationPartial); - const displayQuota: MainPolicyQuotaObservation = isMain && mainWriter && policyQuota?.shortWindowAbsent === true + // Retiring a short tuple releases a routing block, so it needs the same writer proof as + // history: a live main writer for main, a live captured pool writer for pool requests. + const mayRetireShort = isMain ? mainWriter !== undefined : livePoolEvidence; + const displayQuota: MainPolicyQuotaObservation = mayRetireShort && policyQuota?.shortWindowAbsent === true ? { ...quota, shortWindowAbsent: true } : quota; const next = mergeAccountQuota(displayQuota, legacyExisting, updatedAt); @@ -797,7 +807,12 @@ function schedulePersistAccountQuotas(): void { persistTimer = setTimeout(() => { persistTimer = null; persistStartedAt = null; - try { + // Admitted after an overlapping optional config-root ACL flight (#7815); the snapshot is + // taken inside the callback, and a timed-out admission skips this best-effort write. + const dir = getConfigDir(); + const generation = persistGeneration; + persistWrite = withConfigDirHardeningSettled(dir, () => { + if (generation !== persistGeneration) return; const quotas: Record = {}; for (const [accountId, quota] of accountQuota.entries()) { quotas[accountId] = quota; @@ -808,10 +823,10 @@ function schedulePersistAccountQuotas(): void { ...(mainPolicyQuota ? { mainPolicyQuota } : {}), history: quotaHistory.serialize(), }; - atomicWriteFile(join(getConfigDir(), QUOTA_CACHE_FILENAME), `${JSON.stringify(body)}\n`); - } catch { + atomicWriteFile(join(dir, QUOTA_CACHE_FILENAME), `${JSON.stringify(body)}\n`); + }).catch(() => { // Best-effort persistence only. - } + }); }, delay); } @@ -887,6 +902,7 @@ export function clearAccountQuota(accountId?: string): void { persistTimer = null; } persistStartedAt = null; + persistGeneration += 1; try { const path = join(getConfigDir(), QUOTA_CACHE_FILENAME); if (existsSync(path)) unlinkSync(path); @@ -934,7 +950,14 @@ export function parseMainPolicyUsageQuota(data: WhamUsageResponse): MainPolicyQu const windows = [data.rate_limit?.primary_window, data.rate_limit?.secondary_window, data.rate_limit?.tertiary_window]; if (windows.some(window => isInvalidPolicyUsagePercent(window?.used_percent))) return null; const quota = filterMainPolicyMonthlyQuota(parseUsageQuota(data), isThirtyDayOnlyCodexPlan(data.plan_type)); - const [primary, secondary, tertiary] = windows; + return quota ? withWhamShortWindowAbsence(data, quota) : null; +} + +/** Derive absence from complete WHAM topology without changing the display quota. */ +export function withWhamShortWindowAbsence( + data: WhamUsageResponse, quota: Omit, +): MainPolicyQuotaObservation { + const [primary, secondary, tertiary] = [data.rate_limit?.primary_window, data.rate_limit?.secondary_window, data.rate_limit?.tertiary_window]; // Primary and secondary must explicitly report their topology. WHAM also sends // two-window responses without tertiary; an explicit allowed verdict disambiguates those // from partial observations. A present tertiary still requires measured long/null evidence. @@ -942,7 +965,7 @@ export function parseMainPolicyUsageQuota(data: WhamUsageResponse): MainPolicyQu // carries a valid usage reading: a long window without used_percent leaves that // window's usage unknown, and unknown usage must never release a block. // Headers never supply this proof, and reset time alone still cannot release a block. - if (quota && (isMeasuredLongWindow(primary) + if ((isMeasuredLongWindow(primary) || (primary === null && isMeasuredLongWindow(secondary) && quota.weeklyPercent !== undefined)) && (secondary === null || isMeasuredLongWindow(secondary)) && (tertiary === null || isMeasuredLongWindow(tertiary) @@ -952,6 +975,25 @@ export function parseMainPolicyUsageQuota(data: WhamUsageResponse): MainPolicyQu return quota; } +/** + * Policy evidence for a stored pool account's WHAM read (pool probe or re-login): null when any + * reading is invalid. A complete long/null topology marks short-window absence (#7961) unless + * upstream explicitly denied the account, since a denial is never evidence that a block ended. + */ +export function poolWhamPolicyObservation( + data: WhamUsageResponse, quota: Omit | null, +): MainPolicyQuotaObservation | null { + if (!quota || !isValidWhamHistoryObservation(data)) return null; + // Retirement releases a block, so a present reading that does not parse is not "unknown". + const readings = [data.rate_limit?.primary_window, data.rate_limit?.secondary_window, data.rate_limit?.tertiary_window]; + for (const limit of Array.isArray(data.additional_rate_limits) ? data.additional_rate_limits : []) { + if (limit && typeof limit === "object") readings.push(limit.rate_limit?.primary_window, limit.rate_limit?.secondary_window); + } + const malformed = readings.some(window => window?.used_percent != null + && normalizeUsagePercent(window.used_percent) === undefined); + return data.rate_limit?.allowed === false || malformed ? quota : withWhamShortWindowAbsence(data, quota); +} + function isMeasuredLongWindow(window: WhamUsageWindow | null | undefined): boolean { return isExplicitLongWindow(window) && normalizeUsagePercent(window?.used_percent) !== undefined; } diff --git a/src/codex/role-auto-assign.ts b/src/codex/role-auto-assign.ts index f2a29cea535..8fde0b66b2f 100644 --- a/src/codex/role-auto-assign.ts +++ b/src/codex/role-auto-assign.ts @@ -4,19 +4,23 @@ * * Tier of a candidate model, first rule that applies: * 1. codexRoleTiers in the opencodex config lists it under fast, standard or frontier. - * 2. It has a known price. Priced candidates are ranked by input plus output USD per 1M tokens. + * 2. At least two candidates have a measured score in the role's task domain. Measured candidates + * are ranked by that score and split across the tiers the same way price splits them below; + * unmeasured ones are left out rather than placed among measured ones by price. + * 3. It has a known price. Priced candidates are ranked by input plus output USD per 1M tokens. * Three or more split evenly across the three tiers by rank, cheapest in fast. Fewer than three * cannot fill every tier, so the ranking is anchored at the top: the dearest is frontier, * because it is the strongest model on offer, and each cheaper one sits one tier lower. One * priced model is frontier; two are standard and frontier. The tier left empty is fast, so a * fast role steps up to the cheapest model and a standard role keeps it instead of jumping to * the dearest, which keeps every role on the cheapest sufficient model on offer. - * 3. Otherwise it is unclassified and never proposed. + * 4. Otherwise it is unclassified and never proposed. * * A role gets the cheapest sufficient model: the lowest tier at or above what it was sized for, - * then the lowest price, then the model the role already uses when it is one of the tied models, - * then the picker's own order. The picker lists native models first, so without the current-model - * step a role on a routed model moved to a native one priced and tiered the same. + * then the lowest price, then the higher measured score, then the model the role already uses when + * it is one of the tied models, then the picker's own order. The picker lists native models first, + * so without the current-model step a role on a routed model moved to a native one priced and + * tiered the same. * * Effort binds to positions on the chosen model's ladder: glance is the floor, measured is the * model's default or the middle rung, exhaustive is the ceiling, and thorough is the rung above @@ -25,7 +29,7 @@ * above wins because thorough asks for more than the everyday setting. Nothing resolves outside * the ladder. */ -import { SIZING_TIERS, type RoleSizingOutcome, type SizingEffort, type SizingTier } from "./role-sizing"; +import { SIZING_TIERS, type RoleSizingOutcome, type SizingDomain, type SizingEffort, type SizingTier } from "./role-sizing"; import { CODEX_REASONING_LEVELS } from "../reasoning-effort"; export type RoleTierMapping = Partial>; @@ -40,7 +44,9 @@ export interface RoleModelCandidate { export interface ClassifiedCandidate extends RoleModelCandidate { readonly tier: SizingTier | null; - readonly tierSource: "mapping" | "price" | null; + readonly tierSource: "mapping" | "measured" | "price" | null; + /** The measured score the tier came from, or null when it came from elsewhere. */ + readonly score: number | null; } const LADDER_ORDER = ["none", "minimal", ...CODEX_REASONING_LEVELS.map(level => level.effort)]; @@ -66,33 +72,45 @@ export function mapEffortToLevel(intent: SizingEffort, efforts: readonly string[ } } +/** Split items ranked weakest first across the tiers; fewer than three are anchored at the top. */ +function tierByRank(ranked: readonly T[]): Map { + const top = SIZING_TIERS.length - 1; + return new Map(ranked.map((item, rank) => [item, SIZING_TIERS[ranked.length < SIZING_TIERS.length + ? top - (ranked.length - 1 - rank) + : Math.round((rank * top) / (ranked.length - 1))]!])); +} + export function classifyRoleModelCandidates( candidates: readonly RoleModelCandidate[], mapping: RoleTierMapping = {}, + measured: ReadonlyMap = new Map(), ): ClassifiedCandidate[] { const mapped = new Map(); for (const tier of SIZING_TIERS) { for (const model of mapping[tier] ?? []) if (!mapped.has(model)) mapped.set(model, tier); } - const priced = candidates - .map((candidate, order) => ({ candidate, order })) - .filter(({ candidate }) => !mapped.has(candidate.model) && candidate.unitPrice !== null) - .sort((a, b) => a.candidate.unitPrice! - b.candidate.unitPrice! || a.order - b.order); - const byPrice = new Map(); - priced.forEach(({ candidate }, rank) => { - const top = SIZING_TIERS.length - 1; - const index = priced.length < SIZING_TIERS.length - ? top - (priced.length - 1 - rank) - : Math.round((rank * top) / (priced.length - 1)); - byPrice.set(candidate.model, SIZING_TIERS[index]!); - }); + const unmapped = candidates.filter(candidate => !mapped.has(candidate.model)); + const scored = unmapped.filter(candidate => measured.has(candidate.model)); + const scoreTiers = tierByRank([...new Set(scored.map(candidate => measured.get(candidate.model)!))].sort((a, b) => a - b)); + const byScore = scored.length >= 2 + ? new Map(scored.map(candidate => [candidate.model, scoreTiers.get(measured.get(candidate.model)!)!] as const)) + : new Map(); + const byPrice = byScore.size > 0 + ? new Map() + : tierByRank(unmapped + .map((candidate, order) => ({ candidate, order })) + .filter(({ candidate }) => candidate.unitPrice !== null) + .sort((a, b) => a.candidate.unitPrice! - b.candidate.unitPrice! || a.order - b.order) + .map(({ candidate }) => candidate.model)); return candidates.map(candidate => { const fromMapping = mapped.get(candidate.model); - if (fromMapping) return { ...candidate, tier: fromMapping, tierSource: "mapping" as const }; + if (fromMapping) return { ...candidate, tier: fromMapping, tierSource: "mapping" as const, score: null }; + const fromScore = byScore.get(candidate.model); + if (fromScore) return { ...candidate, tier: fromScore, tierSource: "measured" as const, score: measured.get(candidate.model)! }; const fromPrice = byPrice.get(candidate.model); return fromPrice - ? { ...candidate, tier: fromPrice, tierSource: "price" as const } - : { ...candidate, tier: null, tierSource: null }; + ? { ...candidate, tier: fromPrice, tierSource: "price" as const, score: null } + : { ...candidate, tier: null, tierSource: null, score: null }; }); } @@ -107,6 +125,7 @@ export function cheapestSufficientCandidate( .filter(entry => entry.rank >= need) .sort((a, b) => a.rank - b.rank || (a.candidate.unitPrice ?? Infinity) - (b.candidate.unitPrice ?? Infinity) + || (b.candidate.score ?? -Infinity) - (a.candidate.score ?? -Infinity) || Number(b.candidate.model === current) - Number(a.candidate.model === current) || a.order - b.order); return ranked[0]?.candidate ?? null; @@ -123,10 +142,13 @@ export type RoleProposal = RoleProposalInput & ( readonly status: "proposed" | "unassigned"; readonly tier: SizingTier; readonly effortIntent: SizingEffort; + readonly domain: SizingDomain; readonly rationale: string; readonly moveUpIf: string; readonly moveDownIf: string; readonly proposedModel: string | null; + /** Where the picked model's tier came from: a user mapping, a measured score, or price rank. */ + readonly tierSource: ClassifiedCandidate["tierSource"]; /** The sized effort on the picked model's ladder; null only when that model has no ladder. */ readonly proposedEffort: string | null; readonly reason: string | null; @@ -138,23 +160,25 @@ export function buildRoleProposals( roles: readonly RoleProposalInput[], sizing: ReadonlyMap, classified: readonly ClassifiedCandidate[], + classifiedByDomain: ReadonlyMap = new Map(), ): RoleProposal[] { return roles.map(role => { const outcome = sizing.get(role.role) ?? { unsized: "the role was not sized" }; if ("unsized" in outcome) return { ...role, status: "unsized", reason: outcome.unsized }; - const { tier, effort, rationale, moveUpIf, moveDownIf } = outcome.sizing; - const pick = cheapestSufficientCandidate(classified, tier, role.model); - const sized = { ...role, tier, effortIntent: effort, rationale, moveUpIf, moveDownIf }; + const { tier, effort, domain, rationale, moveUpIf, moveDownIf } = outcome.sizing; + const pick = cheapestSufficientCandidate(classifiedByDomain.get(domain) ?? classified, tier, role.model); + const sized = { ...role, tier, effortIntent: effort, domain, rationale, moveUpIf, moveDownIf }; if (!pick) { return { ...sized, status: "unassigned", proposedModel: null, + tierSource: null, proposedEffort: null, reason: `no available model is classified ${tier} or above`, }; } const proposedEffort = mapEffortToLevel(effort, pick.efforts, pick.defaultEffort); - return { ...sized, status: "proposed", proposedModel: pick.model, proposedEffort, reason: null }; + return { ...sized, status: "proposed", proposedModel: pick.model, tierSource: pick.tierSource, proposedEffort, reason: null }; }); } diff --git a/src/codex/role-capability.ts b/src/codex/role-capability.ts new file mode 100644 index 00000000000..1edb0d1ded0 --- /dev/null +++ b/src/codex/role-capability.ts @@ -0,0 +1,160 @@ +/** + * Measured capability per task domain, so role auto-assign can tier candidates by what they score + * instead of what they cost. Artificial Analysis is read live when configured: + * + * - Artificial Analysis (https://artificialanalysis.ai/api-reference), when the user configures + * their own API key. Its terms allow that user's internal use with attribution and forbid + * redistributing the raw data, so its scores never leave this process. + * + * Each domain maps to one published index. The source names a model by display name + * with one row per reasoning setting ("Claude Opus 5.5 (High)"); capabilityModelKey drops the + * setting and folds the rest to a slug, which matches a catalog id once its provider prefix is + * gone ("cpa/claude-opus-5-5"). The best setting's score stands for the model, since a role can + * be given that setting. A candidate with no row is unmeasured, never guessed. + */ +import { resolveProviderApiKey } from "../providers/api-key-resolve"; +import type { OcxConfig } from "../types"; +import type { SizingDomain } from "./role-sizing"; + +export interface CapabilitySource { + readonly name: string; + readonly url: string; + readonly license: string; + /** Whether the terms let a response carry the raw scores. */ + readonly redistributable: boolean; +} + +export const ARTIFICIAL_ANALYSIS_SOURCE: CapabilitySource = { + name: "Artificial Analysis", + url: "https://artificialanalysis.ai", + license: "Artificial Analysis API terms: internal use, attribution required, no redistribution", + redistributable: false, +}; + +const ARTIFICIAL_ANALYSIS_URL = "https://artificialanalysis.ai/api/v2/data/llms/models"; + +/** Per domain, the indexes to read in order; the first one the response carries is used. */ +const INTELLIGENCE_INDEX = "artificial_analysis_intelligence_index"; +const ARTIFICIAL_ANALYSIS_INDEXES: Record = { + coding: ["artificial_analysis_coding_index", INTELLIGENCE_INDEX], + agentic: ["artificial_analysis_agentic_index", INTELLIGENCE_INDEX], + reasoning: ["artificial_analysis_math_index", INTELLIGENCE_INDEX], + writing: [INTELLIGENCE_INDEX], + knowledge: [INTELLIGENCE_INDEX], + general: [INTELLIGENCE_INDEX], +}; + +const FETCH_TIMEOUT_MS = 30_000; +/** Each published table is under 1 MB today; 16 MB leaves room to grow and still bounds memory. */ +export const MAX_TABLE_BYTES = 16 * 1024 * 1024; + +export interface CapabilityTable { + readonly source: CapabilitySource | null; + readonly publishedAt: string | null; + /** Per domain: model key to its best published score. */ + readonly domains: Partial>>; + /** Why a configured source was passed over for this one. */ + readonly fallbackReason?: string; +} + +export type LoadCapability = (config: OcxConfig) => Promise; + +export function capabilityModelKey(name: string): string { + return name.toLowerCase().replace(/\([^)]*\)/g, " ").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); +} + +export function candidateModelKey(slug: string): string { + return capabilityModelKey(slug.slice(slug.lastIndexOf("/") + 1)); +} + +function keepBest(scores: Map, key: string, value: number) { + if (key) scores.set(key, Math.max(value, scores.get(key) ?? -Infinity)); +} + +export function capabilityTableFromArtificialAnalysis(body: unknown): CapabilityTable { + const rows = (body as { data?: unknown } | null)?.data; + if (!Array.isArray(rows)) throw new Error("Artificial Analysis returned no model list"); + const domains: Partial>> = {}; + for (const [domain, indexes] of Object.entries(ARTIFICIAL_ANALYSIS_INDEXES) as [SizingDomain, readonly string[]][]) { + for (const index of indexes) { + const scores = new Map(); + for (const row of rows as Record[]) { + const value = (row?.evaluations as Record | undefined)?.[index]; + if (typeof value !== "number" || !Number.isFinite(value)) continue; + if (typeof row.name === "string") keepBest(scores, capabilityModelKey(row.name), value); + if (typeof row.slug === "string") keepBest(scores, capabilityModelKey(row.slug), value); + } + if (scores.size > 0) { + domains[domain] = scores; + break; + } + } + } + return { source: ARTIFICIAL_ANALYSIS_SOURCE, publishedAt: null, domains }; +} + +/** The body's bytes, refused once they pass maxBytes, whatever the transport declared. */ +export async function readBoundedBytes(response: Response, maxBytes: number): Promise { + const chunks: Uint8Array[] = []; + let length = 0; + const reader = response.body?.getReader(); + if (!reader) return new ArrayBuffer(0); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (length + value.byteLength > maxBytes) { + await reader.cancel(); + throw new Error("response exceeded byte bound"); + } + chunks.push(value); + length += value.byteLength; + } + const out = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.byteLength; + } + return out.buffer; +} + +export async function fetchArtificialAnalysisTable(apiKey: string, fetchImpl: typeof fetch = fetch): Promise { + const response = await fetchImpl(ARTIFICIAL_ANALYSIS_URL, { + redirect: "error", + headers: { "x-api-key": apiKey }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const bytes = await readBoundedBytes(response, MAX_TABLE_BYTES); + return capabilityTableFromArtificialAnalysis(JSON.parse(new TextDecoder().decode(bytes))); +} + +/** One fetch per period key; a failed fetch is forgotten so the next request retries. */ +function periodCache() { + let slot: { key: string; table: Promise } | null = null; + return (key: string, fetchTable: () => Promise): Promise => { + if (slot?.key === key) return slot.table; + const table = fetchTable(); + slot = { key, table }; + table.catch(() => { if (slot?.table === table) slot = null; }); + return table; + }; +} + +const artificialAnalysisCache = periodCache(); + +/** + * Artificial Analysis when the user's key resolves, read once per UTC day to stay inside the + * free tier's daily request quota. Absent or unavailable scores leave price ranking in charge. + */ +export const loadCapabilityTable: LoadCapability = async config => { + const now = new Date().toISOString(); + const unavailable = (fallbackReason: string): CapabilityTable => ({ source: null, publishedAt: null, domains: {}, fallbackReason }); + const apiKey = resolveProviderApiKey(config.codexRoleCapability?.artificialAnalysis?.apiKey)?.trim(); + if (!apiKey) return unavailable("benchmark data was unavailable: no Artificial Analysis key is configured"); + try { + return await artificialAnalysisCache(`${now.slice(0, 10)} ${apiKey}`, () => fetchArtificialAnalysisTable(apiKey)); + } catch { + return unavailable("Artificial Analysis could not be read"); + } +}; diff --git a/src/codex/role-sizing.ts b/src/codex/role-sizing.ts index 2d1a42ffa94..a8e9b384e79 100644 --- a/src/codex/role-sizing.ts +++ b/src/codex/role-sizing.ts @@ -43,6 +43,10 @@ export type SizingTier = typeof SIZING_TIERS[number]; export const SIZING_EFFORTS = ["glance", "measured", "thorough", "exhaustive"] as const; export type SizingEffort = typeof SIZING_EFFORTS[number]; +/** Task domains, the kind of capability a tier is measured in. A label, never a model or a vendor. */ +export const SIZING_DOMAINS = ["coding", "agentic", "reasoning", "writing", "knowledge", "general"] as const; +export type SizingDomain = typeof SIZING_DOMAINS[number]; + const MAX_FIELD_CHARS = 400; export const ROLE_SIZING_SYSTEM_PROMPT = [ @@ -66,6 +70,15 @@ export const ROLE_SIZING_SYSTEM_PROMPT = [ "or a strong model needs only a quick call. Effort buys deliberation, never capability, and more", "effort is not more correct.", "", + "Task domain names the kind of capability the tier is measured in. It sizes nothing; it tells the", + "mapping which measured strength matters, because models of one price class differ by domain:", + "- coding: writing, changing, or reviewing code in a repository.", + "- agentic: long multi-step tool use, where the work is completing a run of actions without derailing.", + "- reasoning: math, logic, planning, or analysis whose answer is derived rather than looked up.", + "- writing: prose meant for a reader: documents, messages, copy.", + "- knowledge: research and factual recall across sources.", + "- general: none of the above dominates. Pick it rather than guess.", + "", "Assess risk and complexity once for both dials:", "- file, module, or ownership boundary crossing;", "- reversibility and blast radius;", @@ -86,8 +99,8 @@ export const ROLE_SIZING_SYSTEM_PROMPT = [ "- Use only the neutral words above. Do not name concrete model products, vendors, versions, or vendor effort levels anywhere in the answer.", "", "Answer with one JSON object and nothing else, no prose and no code fence:", - '{"roles":{"":{"tier":"fast|standard|frontier","effort":"glance|measured|thorough|exhaustive","rationale":"","move_up_if":"","move_down_if":""}}}', - "Include every role you were given, keyed by its exact name, with exactly those five fields.", + '{"roles":{"":{"tier":"fast|standard|frontier","effort":"glance|measured|thorough|exhaustive","domain":"coding|agentic|reasoning|writing|knowledge|general","rationale":"","move_up_if":"","move_down_if":""}}}', + "Include every role you were given, keyed by its exact name, with exactly those six fields.", ].join("\n"); /** @@ -105,6 +118,7 @@ export const DELEGATED_WORK_SIZING_SYSTEM_PROMPT = [ export interface RoleSizing { readonly tier: SizingTier; readonly effort: SizingEffort; + readonly domain: SizingDomain; readonly rationale: string; readonly moveUpIf: string; readonly moveDownIf: string; @@ -167,13 +181,16 @@ function validateEntry(entry: unknown): RoleSizingOutcome { const fields = entry as Record; if (!isMember(SIZING_TIERS, fields.tier)) return { unsized: "the sizing answer has no valid tier" }; if (!isMember(SIZING_EFFORTS, fields.effort)) return { unsized: "the sizing answer has no valid effort" }; + // A missing or unknown domain still sizes the role on the overall scale: the domain only picks + // which measured capability ranks the candidates, and "general" is the rubric's own no-signal answer. + const domain = isMember(SIZING_DOMAINS, fields.domain) ? fields.domain : "general"; const rationale = oneLine(fields.rationale); const moveUpIf = oneLine(fields.move_up_if); const moveDownIf = oneLine(fields.move_down_if); if (rationale === null || moveUpIf === null || moveDownIf === null) { return { unsized: "the sizing answer is missing a rationale or a move trigger" }; } - return { sizing: { tier: fields.tier, effort: fields.effort, rationale, moveUpIf, moveDownIf } }; + return { sizing: { tier: fields.tier, effort: fields.effort, domain, rationale, moveUpIf, moveDownIf } }; } const CODE_FENCE = /^\x60{3}(?:json)?[ \t]*\r?\n([\s\S]*?)\r?\n[ \t]*\x60{3}$/i; @@ -183,9 +200,10 @@ const CODE_FENCE = /^\x60{3}(?:json)?[ \t]*\r?\n([\s\S]*?)\r?\n[ \t]*\x60{3}$/i; * * The answer must be one JSON object of the shape the rubric asks for. Two leniencies, both for * content the mapping never reads: a single surrounding code fence, which models add out of habit, - * and keys beyond the five fields in a role's entry, which are ignored. Every one of the five must - * still be present and valid. Anything else wrong marks the affected role, or every role when the - * whole answer is unusable, as unsized with the reason. + * and keys beyond the six fields in a role's entry, which are ignored. Tier, effort, rationale and + * both move triggers must still be present and valid; a missing domain falls back to "general". + * Anything else wrong marks the affected role, or every role when the whole answer is unusable, + * as unsized with the reason. */ export function parseRoleSizingResponse(text: string, roles: readonly string[]): Map { const out = new Map(); diff --git a/src/codex/routing/cooldown-math.ts b/src/codex/routing/cooldown-math.ts index 515b4d9eb22..858d98b395f 100644 --- a/src/codex/routing/cooldown-math.ts +++ b/src/codex/routing/cooldown-math.ts @@ -3,7 +3,7 @@ import { CODEX_UNKNOWN_USAGE_SCORE, } from "../quota"; import { isThirtyDayOnlyCodexPlan } from "../plan"; -import { hasSpendableCodexCredits, isTerminalShortWindow, type CodexSpendableCredits } from "../quota-types"; +import { hasElapsedShortWindow, hasSpendableCodexCredits, isTerminalShortWindow, type CodexSpendableCredits } from "../quota-types"; import type { CodexQuotaScope } from "./health-store"; import type { TransientProbeGrant } from "./thread-affinity"; import type { MainQuotaDispatch } from "../main-account-cache"; @@ -136,7 +136,8 @@ export function computeCodexUsageScore(quota: { ? (allowCredits && hasSpendableCodexCredits(quota, now)) ? CODEX_EXHAUSTED_USAGE_PERCENT - 1 : CODEX_EXHAUSTED_USAGE_PERCENT : CODEX_UNKNOWN_USAGE_SCORE; } - const values = finite(quota.shortPercent) ? [...knownLong, quota.shortPercent] : knownLong; + const values = finite(quota.shortPercent) && !hasElapsedShortWindow(quota.shortResetAt, now) + ? [...knownLong, quota.shortPercent] : knownLong; const score = Math.max(...values); // Keep included-capacity accounts ahead of a paid overage account, but do not // classify the latter as exhausted solely from its subscription percentages. diff --git a/src/codex/runtime.ts b/src/codex/runtime.ts index d0e9d1538ef..8de8bed5e17 100644 --- a/src/codex/runtime.ts +++ b/src/codex/runtime.ts @@ -13,6 +13,7 @@ import { import { macCodexAppBundleFor, macCodexAppCommands } from "./mac-app-bundle"; import { beginCodexCliSelectionParentWrite, + withCodexCliSelectionParentWriteAsync, CodexCliSelectionMutationBusyError, } from "./cli-update-selection-gate"; import { resolveCodexHomeDir } from "./home"; @@ -406,7 +407,7 @@ export function persistCodexRuntime( // window, so a held gate is an immediate busy rather than a wait. Checked // BEFORE the mkdir and the resolve-cache invalidation so a busy refusal // changes nothing at all. - const selectionWrite = beginCodexCliSelectionParentWrite(); + const selectionWrite = beginCodexCliSelectionParentWrite(deps.configDir); if (!selectionWrite.entered) throw new CodexCliSelectionMutationBusyError(); try { const configDir = deps.configDir ?? getConfigDir(); @@ -438,7 +439,7 @@ export function clearPersistedCodexRuntime(deps: ResolveCodexRuntimeDeps = {}): // Same gate as persistCodexRuntime, checked before the cache invalidation // and outside the swallowing catch: a busy refusal must stay an error, not // become a silent "already removed". - const selectionWrite = beginCodexCliSelectionParentWrite(); + const selectionWrite = beginCodexCliSelectionParentWrite(deps.configDir); if (!selectionWrite.entered) throw new CodexCliSelectionMutationBusyError(); try { const configDir = deps.configDir ?? getConfigDir(); @@ -461,6 +462,14 @@ export function clearPersistedCodexRuntime(deps: ResolveCodexRuntimeDeps = {}): } } +export async function persistCodexRuntimeAsync(runtime: ResolvedCodexRuntime, deps: ResolveCodexRuntimeDeps = {}, + origin: CodexRuntimePinOrigin = "pinned"): Promise { + await withCodexCliSelectionParentWriteAsync(() => persistCodexRuntime(runtime, deps, origin), deps); +} +export async function clearPersistedCodexRuntimeAsync(deps: ResolveCodexRuntimeDeps = {}): Promise { + await withCodexCliSelectionParentWriteAsync(() => clearPersistedCodexRuntime(deps), deps); +} + type VersionProbeResult = { ok: true; version: string | null; macAppBundle?: boolean } | { ok: false; reason: string }; /** Checks that need no exec; a non-null answer settles the candidate without running `--version`. */ @@ -1415,7 +1424,38 @@ export async function resolveAndPersistCodexRuntimeAsync( if (deps.signal?.aborted || !isCurrent() || observation.epoch === null || observation.epoch !== resolveCacheEpoch || observation.inputs !== codexRuntimeSelectionIdentity(deps)) return null; if (deps.signal?.aborted) return null; - return cloneAndDeepFreeze(persistResolvedCodexRuntime(observation.result, deps)); + if (resolvedRuntimeMutation(observation.result, deps) === null) return cloneAndDeepFreeze(observation.result); + try { + return await withCodexCliSelectionParentWriteAsync(() => { + // Admission can await Windows ACL subprocesses. Re-prove all selection + // inputs after that await, in the same turn as the persisted mutation. + if (deps.signal?.aborted || !isCurrent() || observation.epoch !== resolveCacheEpoch + || observation.inputs !== codexRuntimeSelectionIdentity(deps)) return null; + return cloneAndDeepFreeze(persistResolvedCodexRuntime(observation.result, deps)); + }, deps); + } catch (error) { + if (deps.signal?.aborted) return null; + if (error instanceof CodexCliSelectionMutationBusyError) { + // A recovery denial also followed an await. Never return the obsolete + // selection as a usable result merely because no commit callback ran. + if (!isCurrent() || observation.epoch !== resolveCacheEpoch + || observation.inputs !== codexRuntimeSelectionIdentity(deps)) return null; + if (observation.result.runtime.source !== "fallback") { + return cloneAndDeepFreeze({ ...observation.result, persistError: error.message }); + } + } + throw error; + } +} + +function resolvedRuntimeMutation(result: ResolveCodexRuntimeResult, deps: ResolveCodexRuntimeDeps): "persist" | "clear" | null { + const persisted = loadPersistedCodexRuntime(deps); + const unchanged = persisted !== null && persisted.command === result.runtime.command + && persisted.source === result.runtime.source && (persisted.selectedVersion ?? null) === (result.runtime.version ?? null); + if (result.runtime.command && result.runtime.source !== "fallback" && !unchanged) return "persist"; + if (result.runtime.source === "fallback" && persisted?.command && result.failures.some(failure => + failure.command.trim() === persisted.command.trim() && failure.reason === PATH_MISSING_REASON)) return "clear"; + return null; } function persistResolvedCodexRuntime( diff --git a/src/codex/shim.ts b/src/codex/shim.ts index 4bac2ed2cae..99b525e2beb 100644 --- a/src/codex/shim.ts +++ b/src/codex/shim.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { beginCodexCliSelectionParentWrite } from "./cli-update-selection-gate"; +import { beginCodexCliSelectionParentWrite, withCodexCliSelectionParentWriteAsync, CodexCliSelectionMutationBusyError } from "./cli-update-selection-gate"; import { existsSync, lstatSync, @@ -984,6 +984,28 @@ export function installCodexShim(deps: RuntimePreflightDeps = {}): { installed: finally { gate.exit(); } } +export async function installCodexShimAsync(deps: RuntimePreflightDeps = {}): Promise> { + try { return await withCodexCliSelectionParentWriteAsync(() => installCodexShim(deps)); } + catch (error) { + if (error instanceof CodexCliSelectionMutationBusyError) return { installed: false, refused: true, message: error.message }; + throw error; + } +} +export async function autoRestoreCodexShimAsync(options: Parameters[0]): Promise { + try { return await withCodexCliSelectionParentWriteAsync(() => autoRestoreCodexShim(options)); } + catch (error) { + if (error instanceof CodexCliSelectionMutationBusyError) return { status: "deferred", message: error.message }; + throw error; + } +} +export async function uninstallCodexShimAsync(): Promise> { + try { return await withCodexCliSelectionParentWriteAsync(uninstallCodexShim); } + catch (error) { + if (error instanceof CodexCliSelectionMutationBusyError) return { removed: false, message: error.message }; + throw error; + } +} + export function autoRestoreCodexShim(options: Parameters[0]): CodexShimAutoRestoreResult { if (process.platform !== "win32") return autoRestoreUnixOverlay(options); const gate = beginCodexCliSelectionParentWrite(); diff --git a/src/config/diagnostics.ts b/src/config/diagnostics.ts index 883c929146d..1e58125e44c 100644 --- a/src/config/diagnostics.ts +++ b/src/config/diagnostics.ts @@ -8,6 +8,7 @@ import { join } from "node:path"; import * as z from "zod/v4"; import { compactionRecoveryConfigError } from "./schema/compaction-recovery"; import { nativeReasoningRetentionConfigError } from "./schema/native-reasoning-retention"; +import { WORKFLOW_BUDGET_SHAPE_HINT, workflowBudgetConfigError } from "./schema/workflow-budget"; import { desktopCompatibilityConfigError } from "./schema/desktop-compatibility"; import { blockedModelRedirectsError } from "./schema/blocked-model-redirects"; import { mainAccountHardLockFallbackError } from "./schema/main-account-hard-lock-fallback"; @@ -145,6 +146,7 @@ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): Conf warnings.push(...degradedListenerWarnings(rawParsed, normalized)); if (blockedModelRedirectsError(rawParsed)) warnings.push("blockedModelRedirects ignored: expected a map of nonempty model keys to nonempty string targets"); if (nativeReasoningRetentionConfigError(rawParsed)) warnings.push("nativeReasoningRetention ignored: expected only optional boolean modelSwitch and accountSwitch fields; both remain off"); + if (workflowBudgetConfigError(rawParsed)) warnings.push("workflowBudget ignored: " + WORKFLOW_BUDGET_SHAPE_HINT + "; the built-in ceilings remain"); if (mainAccountHardLockFallbackError(rawParsed)) warnings.push("codexMainAccountHardLockFallback ignored: expected a nonblank model selector and an optional declared reasoningEffort"); if (fallbackPolicyError(rawParsed)) warnings.push("fallback partly ignored: expected boolean model/provider switches, providers/routes/devices maps of them, and devices..localFallback; ignored leaves keep today's fallback behavior"); const quotaAutoRefreshWarning = degradedCodexQuotaAutoRefreshWarning(rawParsed, normalized); @@ -735,6 +737,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx // drop a refusal on the next whole-config save; the write boundary rejects it instead. ?? fallbackPolicyError(value) ?? nativeReasoningRetentionConfigError(value) + ?? workflowBudgetConfigError(value) ?? mainAccountHardLockFallbackError(value) ?? compactionRecoveryConfigError(value) ?? desktopCompatibilityConfigError(value) ?? configReasoningPinsConfigError(value) ?? blankHostnameError(value) diff --git a/src/config/load-degrade.ts b/src/config/load-degrade.ts index 342ca547722..4ad2bbd2554 100644 --- a/src/config/load-degrade.ts +++ b/src/config/load-degrade.ts @@ -4,6 +4,7 @@ import { chmodSync, existsSync } from "node:fs"; import { join } from "node:path"; import { compactionRecoveryConfigError } from "./schema/compaction-recovery"; import { nativeReasoningRetentionConfigError } from "./schema/native-reasoning-retention"; +import { workflowBudgetConfigError } from "./schema/workflow-budget"; import { desktopCompatibilityConfigError } from "./schema/desktop-compatibility"; import { blockedModelRedirectsError } from "./schema/blocked-model-redirects"; import { mainAccountHardLockFallbackError } from "./schema/main-account-hard-lock-fallback"; @@ -137,6 +138,7 @@ export function warnDegradedCompactionRouting(rawParsed: unknown, validated: Ocx */ export function warnDegradedTopLevelOptIns(rawParsed: unknown, validated: OcxConfig): void { if (nativeReasoningRetentionConfigError(rawParsed)) console.warn("⚠️ invalid nativeReasoningRetention disabled; model and account switch allowances remain off"); + if (workflowBudgetConfigError(rawParsed)) console.warn("⚠️ invalid workflowBudget ignored; the built-in workflow ceilings stay in force"); setCodexWsReuseAcrossTurns(validated.codexWsReuseAcrossTurns === true); const retentionWarning = degradedReasoningRetentionWarning(rawParsed, validated); if (retentionWarning) console.warn(retentionWarning); diff --git a/src/config/process-state.ts b/src/config/process-state.ts index f61f714b8d9..4ac5191e57c 100644 --- a/src/config/process-state.ts +++ b/src/config/process-state.ts @@ -45,7 +45,7 @@ export type RuntimePortState = { siblingOfPort?: number; }; -function isValidRuntimePortState(value: unknown): value is RuntimePortState { +export function isValidRuntimePortState(value: unknown): value is RuntimePortState { if (!value || typeof value !== "object") return false; const state = value as Record; const hostnameOk = state.hostname === undefined || typeof state.hostname === "string"; @@ -201,14 +201,32 @@ export function ocxStartProcessCacheSizeForTests(): number { return ocxStartProcessCache.size; } -function isLikelyOcxStartProcess(pid: number): boolean { +/** + * One observation of whether a candidate PID is an `ocx start` process. + * + * `readPid()` and `verifyPidIdentity()` ask this on every short liveness poll, and the + * read-only tracked-record inspection in `tracked-runtime-inspection.ts` asks it once per + * guarded stop, so all of them share this single cache-backed answer rather than probe + * twice: a hit performs no native command at all, and a miss performs exactly one + * {@link readProcessCommandLine} probe. Only a definite answer is cached; an unreadable + * command line is reported as `"unverifiable"`, left uncached, and re-probed on the next + * ask. `"unverifiable"` is not evidence of a permission boundary, a foreign process, or a + * different account or elevation — only that the probe could not answer this time. + */ +export type OcxStartIdentityObservation = "match" | "mismatch" | "unverifiable"; + +export function inspectOcxStartProcessIdentity(pid: number): OcxStartIdentityObservation { const cached = ocxStartProcessCache.get(pid); - if (cached !== undefined) return cached; + if (cached !== undefined) return cached ? "match" : "mismatch"; const commandLine = readProcessCommandLine(pid); - if (commandLine === undefined) return false; + if (commandLine === undefined) return "unverifiable"; const ok = isOcxStartCommandLine(commandLine); ocxStartProcessCache.set(pid, ok); - return ok; + return ok ? "match" : "mismatch"; +} + +function isLikelyOcxStartProcess(pid: number): boolean { + return inspectOcxStartProcessIdentity(pid) === "match"; } export function readPid(): number | null { diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index 2bacde053c6..b57c3efa537 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -6,6 +6,7 @@ import { accountPauseStateSchema } from "./account-auto-pause"; import { anthropicAccountPoolSchema } from "./anthropic-account-pool"; import { compactionRecoverySchema } from "./compaction-recovery"; import { nativeReasoningRetentionSchema } from "./native-reasoning-retention"; +import { workflowBudgetSchema } from "./workflow-budget"; import { desktopCompatibilitySchema } from "./desktop-compatibility"; import { blockedModelRedirectsSchema } from "./blocked-model-redirects"; import { mainAccountHardLockFallbackSchema } from "./main-account-hard-lock-fallback"; @@ -221,6 +222,8 @@ export const configSchema = z.object({ // A bad hand edit disables only this block; writes validate the catch-free leaf first. reasoningRetention: reasoningRetentionSchema.optional().catch(undefined), nativeReasoningRetention: nativeReasoningRetentionSchema.optional().catch(undefined), + // A bad hand edit falls back to the built-in ceilings; it never turns the guard off. + workflowBudget: workflowBudgetSchema.optional().catch(undefined), desktopCompatibility: desktopCompatibilitySchema.optional().catch(undefined), // A hand-edited malformed phase disables only that phase instead of rejecting // providers/apiKeys, matching the load-time degradation notice; the management write @@ -321,6 +324,14 @@ export const configSchema = z.object({ standard: z.array(z.string().trim().min(1)).optional(), frontier: z.array(z.string().trim().min(1)).optional(), }).strict().optional().catch(undefined), + // Discard the retired source while preserving an otherwise valid Artificial Analysis key. + codexRoleCapability: z.preprocess(value => { + if (!value || typeof value !== "object" || Array.isArray(value)) return value; + const { huggingFace: _retired, ...rest } = value as Record; + return rest; + }, z.object({ + artificialAnalysis: z.object({ apiKey: z.string().trim().min(1).optional() }).strict().optional(), + }).strict().optional().catch(undefined)), codexShimAutoRestore: z.boolean().optional(), codexDesktopAuthless: z.boolean().optional().catch(undefined), codexClientCompaction: z.boolean().optional().catch(undefined), diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts index 245222ad08e..3eacf076d56 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -360,6 +360,7 @@ export const providerConfigSchema = z.object({ inferContextWindow: z.boolean().optional(), modelSupportsServiceTier: z.record(z.string().min(1), z.boolean()).optional(), modelSuppressSyntheticMax: z.record(z.string().min(1), z.boolean()).optional(), + promoteAdditionalTools: z.boolean().optional(), preserveResponsesReasoningContent: z.boolean().optional(), preserveResponsesInputItemIds: z.boolean().optional(), preserveResponsesMessageMetadata: z.boolean().optional(), diff --git a/src/config/schema/workflow-budget.ts b/src/config/schema/workflow-budget.ts new file mode 100644 index 00000000000..7b34fb214b9 --- /dev/null +++ b/src/config/schema/workflow-budget.ts @@ -0,0 +1,43 @@ +import * as z from "zod/v4"; +import type { OcxConfig } from "../../types"; + +/** + * Per-install bounds for the root-workflow count ceilings. + * + * The lower concurrency bound is two because one slot is always held back for the interactive + * turn; at one, every worker turn would be refused. The upper bounds keep each ceiling a + * ceiling: a value large enough to never fire is a way of switching the guard off, and that is + * not what this setting is for. + */ +export const WORKFLOW_BUDGET_BOUNDS = { + maxConcurrentChildren: { min: 2, max: 64 }, + maxPhysicalSends: { min: 1, max: 10_000 }, + maxDistinctChildren: { min: 1, max: 1_024 }, +} as const; + +const bounded = (key: keyof typeof WORKFLOW_BUDGET_BOUNDS) => + z.number().int().min(WORKFLOW_BUDGET_BOUNDS[key].min).max(WORKFLOW_BUDGET_BOUNDS[key].max).optional(); + +/** Omitted fields keep the built-in ceiling, so an absent block is today's behavior byte for byte. */ +export const workflowBudgetSchema = z.object({ + maxConcurrentChildren: bounded("maxConcurrentChildren"), + maxPhysicalSends: bounded("maxPhysicalSends"), + maxDistinctChildren: bounded("maxDistinctChildren"), +}).strict(); + +export type WorkflowBudgetConfig = z.infer; + +export const WORKFLOW_BUDGET_SHAPE_HINT = "accepts only optional integer maxConcurrentChildren (2-64), maxPhysicalSends (1-10000) and maxDistinctChildren (1-1024)"; + +export function workflowBudgetConfigError(value: unknown): string | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const budget = (value as Record).workflowBudget; + return budget === undefined || workflowBudgetSchema.safeParse(budget).success + ? null : "schema_invalid: workflowBudget: " + WORKFLOW_BUDGET_SHAPE_HINT; +} + +/** Malformed input resolves to no overrides, the same way a degraded disk block does. */ +export function readWorkflowBudgetConfig(config: Pick): WorkflowBudgetConfig { + const parsed = workflowBudgetSchema.safeParse(config.workflowBudget); + return parsed.success && parsed.data ? parsed.data : {}; +} diff --git a/src/config/tracked-runtime-inspection.ts b/src/config/tracked-runtime-inspection.ts new file mode 100644 index 00000000000..6cc241e5f19 --- /dev/null +++ b/src/config/tracked-runtime-inspection.ts @@ -0,0 +1,188 @@ +/** + * Read-only classification of the tracked runtime record pair a guarded stop + * re-verifies before acting. + * + * "ocx stop --expect-*" asks "is the recorded runtime still the approved one", + * which collapses several observations into one boolean: a missing or invalid + * "ocx.pid", a dead, foreign or unverifiable recorded process, and a missing, + * invalid or PID-mismatched "runtime-port.json" all read as "not verified". + * The approval-changed refusal used to report only that umbrella fact, so a + * desktop log could not tell a stale record from a permission failure. + * + * This probe walks the same checks in the same order as "readPid()" followed + * by "readRuntimePort(pid)", but reports which one failed instead. Each state + * record is still read exactly once per call -- a guarded stop asks once, and + * caching the bytes would only carry a stale answer into a decision about a + * record that may have been rewritten. The process-identity question, though, + * is answered by the same cache-backed observation "readPid()" itself makes + * ("inspectOcxStartProcessIdentity"): a cached verdict serves this probe with + * no native command at all, and a miss performs the same single command-line + * probe readPid would, so this probe can never disagree with the admission + * readPid just granted or denied for the same PID. It performs no mutation, no + * signal beyond the same "process.kill(pid, 0)" liveness read, and no stop + * decision -- the refusal itself is unchanged; only its detail gains a cause. + * + * Two distinctions are deliberate: + * + * - "permission-denied" is reported only for a real EACCES/EPERM observed on a + * record read or the liveness signal. An "unverifiable" identity observation + * -- an unreadable process command line -- is not proof of a permission + * boundary, and no account or elevation cause is inferred from it. + * - No path, command line, stderr text, or token leaves this module: the refusal + * causes below are fixed strings and the detail built from them is a fixed + * sentence. + */ +import { readFileSync } from "node:fs"; +import { + getPidPath, + getRuntimePortPath, + inspectOcxStartProcessIdentity, + isValidRuntimePortState, + parsePidFile, +} from "./process-state"; +import type { OcxStartIdentityObservation } from "./process-state"; + +/** The single check that failed, as a stable machine-readable token. */ +export type TrackedRecordRefusal = + | "pid-record-absent" + | "pid-record-permission-denied" + | "pid-record-unreadable" + | "pid-record-invalid" + | "process-not-running" + | "process-identity-mismatch" + | "process-unverifiable" + | "runtime-record-absent" + | "runtime-record-permission-denied" + | "runtime-record-unreadable" + | "runtime-record-invalid" + | "runtime-record-pid-mismatch" + | "unverified"; + +export type TrackedRecordInspection = + | { status: "verified"; pid: number; port: number; hostname: string } + | { status: "refused"; reason: TrackedRecordRefusal }; + +/** + * Injectable read-only seams. Tests must supply fakes for "signal" and either + * "identity" or the process-state exec seams ("setProcessCommandLineExecForTests" + * and friends) so no real "process.kill" or native command probe runs; the + * defaults are exactly what the production caller uses, including the shared + * identity cache. + */ +export interface TrackedRecordProbeDeps { + /** Read a state record like "readFileSync(path, 'utf-8')"; throw with errno codes. */ + readFile?: (path: string) => string; + /** The "process.kill(pid, 0)" liveness read; throws like it (ESRCH/EPERM/...). */ + signal?: (pid: number) => void; + /** The shared start-identity observation; the default consults the real cache. */ + identity?: (pid: number) => OcxStartIdentityObservation; +} + +function readRecordFile( + readFile: (path: string) => string, + path: string, + absent: TrackedRecordRefusal, + denied: TrackedRecordRefusal, + unreadable: TrackedRecordRefusal, +): { ok: true; raw: string } | { ok: false; reason: TrackedRecordRefusal } { + try { + return { ok: true, raw: readFile(path) }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "EACCES" || code === "EPERM") return { ok: false, reason: denied }; + if (code === "ENOENT") return { ok: false, reason: absent }; + return { ok: false, reason: unreadable }; + } +} + +/** + * Classify why the tracked runtime record cannot back a guarded stop, or return + * the verified target when every check still holds. One read per record, in the + * same order the stop path already performs them. + */ +export function inspectTrackedRuntimeRecord( + deps: TrackedRecordProbeDeps = {}, +): TrackedRecordInspection { + const readFile = deps.readFile ?? ((path: string) => readFileSync(path, "utf-8")); + const signal = deps.signal ?? ((pid: number) => { process.kill(pid, 0); }); + const identity = deps.identity ?? inspectOcxStartProcessIdentity; + try { + const pidFile = readRecordFile( + readFile, getPidPath(), "pid-record-absent", "pid-record-permission-denied", "pid-record-unreadable", + ); + if (!pidFile.ok) return { status: "refused", reason: pidFile.reason }; + const pid = parsePidFile(pidFile.raw); + if (pid === null) return { status: "refused", reason: "pid-record-invalid" }; + try { + signal(pid); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ESRCH") return { status: "refused", reason: "process-not-running" }; + // EPERM still names a live process; identity is decided by the shared + // observation below, exactly as readPid decides it. Any other signal + // error can only be reported as unverifiable. + if (code !== "EPERM") return { status: "refused", reason: "process-unverifiable" }; + } + const observation = identity(pid); + if (observation === "unverifiable") { + return { status: "refused", reason: "process-unverifiable" }; + } + if (observation === "mismatch") { + return { status: "refused", reason: "process-identity-mismatch" }; + } + const runtimeFile = readRecordFile( + readFile, getRuntimePortPath(), + "runtime-record-absent", "runtime-record-permission-denied", "runtime-record-unreadable", + ); + if (!runtimeFile.ok) return { status: "refused", reason: runtimeFile.reason }; + let parsed: unknown; + try { + parsed = JSON.parse(runtimeFile.raw); + } catch { + return { status: "refused", reason: "runtime-record-invalid" }; + } + if (!isValidRuntimePortState(parsed)) { + return { status: "refused", reason: "runtime-record-invalid" }; + } + if (parsed.pid !== pid) { + return { status: "refused", reason: "runtime-record-pid-mismatch" }; + } + return { status: "verified", pid, port: parsed.port, hostname: parsed.hostname ?? "" }; + } catch { + return { status: "refused", reason: "unverified" }; + } +} + +/** + * Fixed refusal causes for the approval-changed detail. Every entry is a + * complete sentence fragment and contains no path, command line, stderr, or + * token, so the resulting detail is safe to surface in a caller's log or UI. + */ +const TRACKED_REFUSAL_CAUSES: Record = { + "pid-record-absent": "the recorded PID file is absent", + "pid-record-permission-denied": "reading the recorded PID file was denied", + "pid-record-unreadable": "the recorded PID file could not be read", + "pid-record-invalid": "the recorded PID file is not a valid record", + "process-not-running": "the recorded process is no longer running", + "process-identity-mismatch": "the recorded process does not match the expected start command", + "process-unverifiable": "the recorded process identity could not be verified", + "runtime-record-absent": "the tracked runtime record is absent", + "runtime-record-permission-denied": "reading the tracked runtime record was denied", + "runtime-record-unreadable": "the tracked runtime record could not be read", + "runtime-record-invalid": "the tracked runtime record is not a valid record", + "runtime-record-pid-mismatch": "the tracked runtime record names a different PID", + "unverified": "the tracked runtime record could not be re-verified", +}; + +/** + * The guarded stop's tracked() callback shape: the verified target, or the + * fixed cause of the single check that failed. Read-only; decides nothing. + */ +export function describeTrackedRuntimeTarget( + deps: TrackedRecordProbeDeps = {}, +): { pid: number; port: number; hostname: string } | { untracked: string } { + const inspection = inspectTrackedRuntimeRecord(deps); + return inspection.status === "verified" + ? { pid: inspection.pid, port: inspection.port, hostname: inspection.hostname } + : { untracked: TRACKED_REFUSAL_CAUSES[inspection.reason] }; +} diff --git a/src/lib/bun-runtime-preflight.ts b/src/lib/bun-runtime-preflight.ts index 19b9ca3ca09..3f3e7f6a68b 100644 --- a/src/lib/bun-runtime-preflight.ts +++ b/src/lib/bun-runtime-preflight.ts @@ -19,9 +19,10 @@ export class RuntimePreflightError extends Error { } } -type DirectoryIdentity = { dev: number; ino: number; isDirectory(): boolean; isSymbolicLink(): boolean }; +// bigint: NTFS file IDs exceed 2^53, where adjacent IDs are equal as numbers. +type DirectoryIdentity = { dev: bigint; ino: bigint; isDirectory(): boolean; isSymbolicLink(): boolean }; export interface RuntimePreflightFs { - lstatSync: (path: string) => DirectoryIdentity; + lstatSync: (path: string, options: { bigint: true }) => DirectoryIdentity; mkdirSync: (path: string, mode: number) => unknown; rmdirSync: (path: string) => void; readdirSync: (path: string) => readonly string[]; @@ -44,7 +45,7 @@ export interface RuntimePreflightDeps { const probeFs: RuntimePreflightFs = { lstatSync, mkdirSync, rmdirSync, readdirSync }; function sameDirectory(fs: RuntimePreflightFs, path: string, owned: DirectoryIdentity): boolean { - const current = fs.lstatSync(path); + const current = fs.lstatSync(path, { bigint: true }); return current.isDirectory() && !current.isSymbolicLink() && current.dev === owned.dev && current.ino === owned.ino; } @@ -59,7 +60,7 @@ function removeOwnedEmptyDirectory(fs: RuntimePreflightFs, path: string, owned: function prepareRoot(fs: RuntimePreflightFs, root: string, rootWasAbsent?: boolean): void { try { if (rootWasAbsent === undefined) { - try { fs.lstatSync(root); rootWasAbsent = false; } + try { fs.lstatSync(root, { bigint: true }); rootWasAbsent = false; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; rootWasAbsent = true; @@ -73,7 +74,7 @@ function prepareRoot(fs: RuntimePreflightFs, root: string, rootWasAbsent?: boole // Use a concurrently created root, subject to the directory check below. } } - const current = fs.lstatSync(root); + const current = fs.lstatSync(root, { bigint: true }); if (!current.isDirectory() || current.isSymbolicLink()) throw new RuntimePreflightError("create"); } catch { throw new RuntimePreflightError("create"); @@ -83,7 +84,7 @@ function prepareRoot(fs: RuntimePreflightFs, root: string, rootWasAbsent?: boole function probeInProcess(fs: RuntimePreflightFs, path: string): void { let owned: DirectoryIdentity | undefined; try { - try { fs.mkdirSync(path, 0o700); owned = fs.lstatSync(path); } + try { fs.mkdirSync(path, 0o700); owned = fs.lstatSync(path, { bigint: true }); } catch { throw new RuntimePreflightError("create"); } try { fs.rmdirSync(path); } catch { throw new RuntimePreflightError("remove"); } @@ -96,10 +97,10 @@ function probeChild(runtime: DurableBunRuntime, path: string, nonce: string, spa // The child emits only this invocation's nonce and a fixed result, never paths/errors. const script = `const fs = require("node:fs"); const path = ${JSON.stringify(path)}; let result = "create", owned; -try { fs.mkdirSync(path, 0o700); owned = fs.lstatSync(path); result = "remove"; +try { fs.mkdirSync(path, 0o700); owned = fs.lstatSync(path, { bigint: true }); result = "remove"; fs.rmdirSync(path); result = "ok"; } catch { /* result already names the failed step; details never leave the child */ } finally { - if (owned) { try { const now = fs.lstatSync(path); + if (owned) { try { const now = fs.lstatSync(path, { bigint: true }); if (now.isDirectory() && !now.isSymbolicLink() && now.dev === owned.dev && now.ino === owned.ino && fs.readdirSync(path).length === 0) fs.rmdirSync(path); } catch { /* leave any probe entry that cannot be proven ours */ } } diff --git a/src/lib/state-store-registrations.ts b/src/lib/state-store-registrations.ts index 2eb6488eb39..22973b92f58 100644 --- a/src/lib/state-store-registrations.ts +++ b/src/lib/state-store-registrations.ts @@ -11,6 +11,7 @@ import { reconcilePoolRotationState } from "../codex/pool-rotation"; import { reconcileCodexQuotaAccounts } from "../codex/quota"; import { reconcileQuotaRecovery, sweepExpiredQuotaRecovery } from "../codex/quota-401-recovery"; import { bindUpstreamRequestCompressionConfig } from "../server/responses/request-compression"; +import { bindWorkflowBudgetConfig } from "./workflow-budget-config"; import { listLiveCodexAccountIds, reconcileCodexRoutingHealth, @@ -82,6 +83,7 @@ export function setLiveStateStoreConfig(config: OcxConfig): void { // The one place the server's live config object is installed; detached configs (catalog sync) // never come through here, so the outbound compression opt-in follows the running server only. bindUpstreamRequestCompressionConfig(config); + bindWorkflowBudgetConfig(config); notifyLiveConfigObservers(config); } diff --git a/src/lib/windows-secret-acl.ts b/src/lib/windows-secret-acl.ts index 7b423974d9e..c7d39f38df7 100644 --- a/src/lib/windows-secret-acl.ts +++ b/src/lib/windows-secret-acl.ts @@ -1207,37 +1207,55 @@ function isTimeoutError(error: unknown): boolean { * which is optional and simply omitted when it is not an allowlisted code. */ function sanitizeAclFailure(cause: unknown): AclFailureDiagnostics | undefined { - if (!cause || typeof cause !== "object" || !("aclFailure" in cause)) return undefined; - const raw = (cause as { aclFailure?: unknown }).aclFailure; - if (!raw || typeof raw !== "object") return undefined; - const f = raw as Record; - if (typeof f.step !== "string" || !ICACLS_STEPS.has(f.step)) return undefined; - if ( - f.stage !== "deadline_before_spawn" && - f.stage !== "spawn_failed" && - f.stage !== "runner_rejected" && - f.stage !== "runner_watchdog" && - f.stage !== "child_result" - ) { - return undefined; - } - if (typeof f.elapsedMs !== "number" || !Number.isFinite(f.elapsedMs)) return undefined; - if (typeof f.budgetMs !== "number" || !Number.isFinite(f.budgetMs)) return undefined; - if (typeof f.success !== "boolean" || typeof f.timedOut !== "boolean") return undefined; - if (f.exitCode !== null && (typeof f.exitCode !== "number" || !Number.isFinite(f.exitCode))) { + // Every read off the untrusted cause — the `in` check, the `aclFailure` + // access and each field — may be a throwing accessor or a getter that + // returns a different value on the next read. Each field is therefore read + // exactly once into a local, and only that local is validated and copied; + // a read that throws drops the whole record rather than letting a raw + // hostile exception replace the sanitized required-mode error. + try { + if (!cause || typeof cause !== "object" || !("aclFailure" in cause)) return undefined; + const raw = (cause as { aclFailure?: unknown }).aclFailure; + if (!raw || typeof raw !== "object") return undefined; + const f = raw as Record; + const step = f.step; + if (typeof step !== "string" || !ICACLS_STEPS.has(step)) return undefined; + const stage = f.stage; + if ( + stage !== "deadline_before_spawn" && + stage !== "spawn_failed" && + stage !== "runner_rejected" && + stage !== "runner_watchdog" && + stage !== "child_result" + ) { + return undefined; + } + const elapsedMs = f.elapsedMs; + if (typeof elapsedMs !== "number" || !Number.isFinite(elapsedMs)) return undefined; + const budgetMs = f.budgetMs; + if (typeof budgetMs !== "number" || !Number.isFinite(budgetMs)) return undefined; + const success = f.success; + if (typeof success !== "boolean") return undefined; + const timedOut = f.timedOut; + if (typeof timedOut !== "boolean") return undefined; + const exitCode = f.exitCode; + if (exitCode !== null && (typeof exitCode !== "number" || !Number.isFinite(exitCode))) { + return undefined; + } + const spawnErrorCode = allowlistedSpawnErrorCode(f.spawnErrorCode); + return { + step: step as IcaclsStep, + stage, + elapsedMs, + budgetMs, + success, + exitCode: exitCode as number | null, + timedOut, + ...(spawnErrorCode !== undefined ? { spawnErrorCode } : {}), + }; + } catch { return undefined; } - const spawnErrorCode = allowlistedSpawnErrorCode(f.spawnErrorCode); - return { - step: f.step as IcaclsStep, - stage: f.stage, - elapsedMs: f.elapsedMs, - budgetMs: f.budgetMs, - success: f.success, - exitCode: f.exitCode as number | null, - timedOut: f.timedOut, - ...(spawnErrorCode !== undefined ? { spawnErrorCode } : {}), - }; } /** Preserve only the bounded machine-readable cause on a sanitized public error. */ diff --git a/src/lib/workflow-budget-config.ts b/src/lib/workflow-budget-config.ts new file mode 100644 index 00000000000..26905568183 --- /dev/null +++ b/src/lib/workflow-budget-config.ts @@ -0,0 +1,39 @@ +/** + * The workflow-budget policy the running server enforces. + * + * The ceilings in workflow-budget.ts are module defaults, and a busy install that legitimately + * runs more parallel subagents than they allow had no way to say so short of patching the + * package. This binds the live config the same way the outbound compression opt-in does: the + * object is installed once, read per decision, and a dashboard write that mutates it in place + * takes effect on the next request without a restart. + * + * Only the three count ceilings move. The window, the interactive reserve and the tracked-root + * bound stay where they are: the window fixes each root's ring geometry when the root first + * appears, so changing it live would leave existing roots measured on a scale no caller reads. + */ +import { readWorkflowBudgetConfig } from "../config/schema/workflow-budget"; +import type { OcxConfig } from "../types"; +import { DEFAULT_WORKFLOW_BUDGET_POLICY, type WorkflowBudgetPolicy } from "./workflow-budget"; + +let boundConfig: Pick | null = null; + +export function bindWorkflowBudgetConfig(config: Pick | null): void { + boundConfig = config; +} + +export function resolveWorkflowBudgetPolicy(config: Pick): WorkflowBudgetPolicy { + const overrides = readWorkflowBudgetConfig(config); + return { + ...DEFAULT_WORKFLOW_BUDGET_POLICY, + ...(overrides.maxConcurrentChildren !== undefined ? { maxConcurrentChildren: overrides.maxConcurrentChildren } : {}), + ...(overrides.maxPhysicalSends !== undefined ? { maxPhysicalSends: overrides.maxPhysicalSends } : {}), + ...(overrides.maxDistinctChildren !== undefined ? { maxDistinctChildren: overrides.maxDistinctChildren } : {}), + }; +} + +/** Policy for the next admission or ceiling check; an unset block is the module default itself. */ +export function currentWorkflowBudgetPolicy(): WorkflowBudgetPolicy { + return boundConfig?.workflowBudget === undefined + ? DEFAULT_WORKFLOW_BUDGET_POLICY + : resolveWorkflowBudgetPolicy(boundConfig); +} diff --git a/src/local-credentials/generation.ts b/src/local-credentials/generation.ts index 0da2acb199c..1c370840f52 100644 --- a/src/local-credentials/generation.ts +++ b/src/local-credentials/generation.ts @@ -212,8 +212,12 @@ export function claimCredentialObservation(home: string, scan: (accounts: readon }); } /** - * Advance on every observed event and rescan; a failed scan leaves an empty vector, which never - * attests. It never waits for the fence: the runtime observer retries asynchronously instead. + * Advance on every observed coverage-relevant event and rescan; a failed scan leaves an empty + * vector, which never attests. It never waits for the fence: the runtime observer retries + * asynchronously instead. "Coverage-relevant" is the watcher's classification in inventory.ts: + * every event on a target name, credential-write residue, or ancestor structural change -- + * but not an ancestor directory's last-write tick from ordinary child churn, which reports no + * credential change and would re-arm the observer's own save forever. */ export function updateCredentialObservation(home: string, owner: string, scan: ((accounts: readonly string[]) => string) | null): void { withCredentialPublicationFence(home, () => { diff --git a/src/local-credentials/inventory.ts b/src/local-credentials/inventory.ts index 2f817b38811..c7349f1a175 100644 --- a/src/local-credentials/inventory.ts +++ b/src/local-credentials/inventory.ts @@ -40,6 +40,121 @@ function assertParents(path: string): void { if (p === parse(p).root) break; } } +/** Watch classification for one parent directory, per basenames seen there. */ +export type WatchEntry = { + /** + * Basenames that are coverage targets themselves: a file whose bytes are scanned or a + * directory whose whole listing is checked. Every event on these is coverage-relevant + * -- an in-place content change reconciles exactly like a rename, even when the scan + * vector ends up identical, because the advance is what proves the observation ran. + */ + names: Set; + /** + * Basenames watched only as ancestors of a deeper target, paired with the identity + * recorded at install. A "rename" always reconciles: a replacement is the signal these + * entries exist to catch. A "change" reconciles too unless the entry re-identifies as + * the same real directory -- on Windows a directory entry reports "change" whenever a + * child is added or removed, so this observer's own local-credentials.json save would + * otherwise re-arm the same reconcile forever. null means the ancestor could not be + * identified at install, and an unverifiable ancestor reconciles on any event. + */ + ancestors: Map; + /** + * Identity of each target name at install (watchEntryIdentity). An unmatched 8.3 short + * name is resolved against these: it reconciles when a target here changed or when the + * alias is a target. Absent means the entry is treated as unresolvable and reconciles. + */ + targetIdentities?: Map; +}; +const CREDENTIAL_TMP_NAME = /^(auth|config|codex-accounts)\.json\.ocx\./i; +/** + * Watcher names are compared the way a case-insensitive volume resolves them. Windows and + * default macOS volumes report a notification under the spelling the writer used, so a + * credential created as AUTH.JSON reaches the watcher under that name while the scan reads + * it as auth.json. Folding can only add reconciles on a case-sensitive volume, never drop one. + */ +function foldWatchName(name: string): string { + return name.toLowerCase().replace(/[. ]+$/, ""); +} +/** + * An 8.3 short name (AUTH~1.JSO, OPENC~1) cannot be mapped back to its long name by + * spelling; it is resolved by identity instead (see shortNameAction). + */ +const SHORT_NAME_ALIAS = /~\d/; +/** ":", "absent", or "unreadable" for one entry of a watched parent. */ +export function watchEntryIdentity(parent: string, child: string): string { + try { + const stat = lstatSync(join(parent, child), { bigint: true }); + return `${stat.dev}:${stat.ino}`; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "ENOENT" ? "absent" : "unreadable"; + } +} +/** + * Windows may report either name of an entry that has both, and a removed entry's short + * name can no longer be looked up. Such an event is coverage-relevant when a target or + * ancestor in this parent no longer has its install identity, or when the alias resolves to + * a target; a change that resolves to a tracked ancestor is that ancestor's tick. Anything + * else -- this observer's own metadata save under a short spelling -- changed no target. + */ +function shortNameAction(parent: string, entry: WatchEntry, event: string, name: string): WatchEventAction { + const recorded = entry.targetIdentities; + if (!recorded) return "reconcile"; + const resolved = watchEntryIdentity(parent, name); + if (resolved === "unreadable") return "reconcile"; + for (const [target, identity] of recorded) { + const now = watchEntryIdentity(parent, target); + if (now === "unreadable" || now !== identity) return "reconcile"; + if (resolved !== "absent" && resolved === now) return "reconcile"; + } + let aliasOfAncestor = false; + for (const [ancestorName, identity] of entry.ancestors) { + const now = ancestorIdentity(parent, ancestorName); + if (identity === null || now === null || now.dev !== identity.dev || now.ino !== identity.ino) return "reconcile"; + if (resolved === `${identity.dev}:${identity.ino}`) aliasOfAncestor = true; + } + return aliasOfAncestor && event !== "change" ? "reconcile" : "ignore"; +} +function foldedLookup(keys: Iterable, key: (item: T) => string, folded: string): T | undefined { + for (const item of keys) if (foldWatchName(key(item)) === folded) return item; + return undefined; +} +function ancestorIdentity(parent: string, child: string): { dev: bigint; ino: bigint } | null { + try { + const stat = lstatSync(join(parent, child), { bigint: true }); + return stat.isDirectory() && !stat.isSymbolicLink() ? { dev: stat.dev, ino: stat.ino } : null; + } catch { return null; } +} +type WatchEventAction = "fail" | "reconcile" | "ignore"; +/** + * The single (parent, event, name) decision behind the observation watchers, exported so + * tests can pin every branch without timing-dependent filesystem notifications. + */ +export function credentialWatchEventAction( + parent: string, entry: WatchEntry, event: string, name: string | null, +): WatchEventAction { + if (name === null) return "fail"; + const folded = foldWatchName(name); + if (foldedLookup(entry.names, n => n, folded) !== undefined) return "reconcile"; + const match = foldedLookup(entry.ancestors.entries(), ([n]) => n, folded); + // A known ancestor whose real name merely looks like write residue or a short name + // (C:\config.json.ocx.home, C:\ocx~1) goes through the identity check below. + if (match === undefined) { + if (CREDENTIAL_TMP_NAME.test(name)) return "reconcile"; + return SHORT_NAME_ALIAS.test(name) ? shortNameAction(parent, entry, event, name) : "ignore"; + } + const ancestor = match[1]; + // Renames (create/delete/move, including a same-identity rename) are structural and + // always reconcile; only "change" may be child-list noise from a same-identity dir. + if (event !== "change" || ancestor === null) return "reconcile"; + try { + const stat = lstatSync(join(parent, name), { bigint: true }); + if (stat.isDirectory() && !stat.isSymbolicLink() && stat.dev === ancestor.dev && stat.ino === ancestor.ino) { + return "ignore"; + } + } catch { /* an ancestor that cannot be re-identified cannot prove the tick is noise */ } + return "reconcile"; +} export function scanLocalCredentials(accounts: readonly string[] = []): { count: number; vector: string; targets: string[] } { const home = getConfigDir(); const codex = getCodexHome(); @@ -166,6 +281,10 @@ export function scanLocalCredentials(accounts: readonly string[] = []): { count: const profiles = countLocalNativeProfileSlots(vault, homeId); if (profiles.active && !main) throw new Error("Active native profile has no readable auth"); count += main + profiles.inactive; + // The whole listing is verified below, so the directory itself is a coverage target: + // an unknown create/delete must reach a watcher even when the child's own name is not. + targets.add(shared); + assertParents(shared); if (exists(shared)) { if (!lstatSync(shared).isDirectory() || lstatSync(shared).isSymbolicLink()) throw new Error("Unsafe native vault"); const expected = new Set([`${homeId}.vault.json`, `${homeId}.stages.json`]); @@ -223,23 +342,28 @@ export function startLocalCredentialObservation(): { stop(): void; reconcile(): } }; const install = (targets: string[]) => { - const watched = new Map>(); + const watched = new Map(); for (const target of targets) { + let leaf = true; for (let path = target; dirname(path) !== path; path = dirname(path)) { const parent = dirname(path); if (!exists(parent)) continue; assertParents(path); - const names = watched.get(parent) ?? new Set(); - names.add(path.slice(parent.length + (parent.endsWith("/") || parent.endsWith("\\") ? 0 : 1))); - watched.set(parent, names); + const entry = watched.get(parent) ?? { names: new Set(), ancestors: new Map(), targetIdentities: new Map() }; + const child = path.slice(parent.length + (parent.endsWith("/") || parent.endsWith("\\") ? 0 : 1)); + if (leaf) { entry.names.add(child); entry.targetIdentities?.set(child, watchEntryIdentity(parent, child)); } + else if (!entry.ancestors.has(child)) entry.ancestors.set(child, ancestorIdentity(parent, child)); + watched.set(parent, entry); + leaf = false; } } const next: FSWatcher[] = []; try { - for (const [parent, names] of watched) { - const watcher = watch(parent, { persistent: false }, (_event, name) => { - if (name === null) { fail(); return; } - if (names.has(String(name)) || /^(auth|config|codex-accounts)\.json\.ocx\./.test(String(name))) reconcile(); + for (const [parent, entry] of watched) { + const watcher = watch(parent, { persistent: false }, (event, name) => { + const action = credentialWatchEventAction(parent, entry, event, name === null ? null : String(name)); + if (action === "fail") fail(); + else if (action === "reconcile") reconcile(); }); watcher.on("error", fail); next.push(watcher); diff --git a/src/providers/account-quota-disk.ts b/src/providers/account-quota-disk.ts index bf0aa1e5160..bf6593f7a98 100644 --- a/src/providers/account-quota-disk.ts +++ b/src/providers/account-quota-disk.ts @@ -13,6 +13,7 @@ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { atomicWriteFile, getConfigDir } from "../config"; +import { withConfigDirHardeningSettled } from "../config/paths"; import type { ProviderQuota } from "./quota-types"; import type { KiroPersistedQuota, KiroPersistedVerdict } from "./kiro-account-state-disk"; import { ACCOUNT_QUOTA_TTL_MS } from "./quota-wire"; @@ -41,6 +42,8 @@ type DiskFile = { let persistTimer: ReturnType | null = null; let persistStartedAt: number | null = null; +/** Bumped by cancellation so a fired callback still waiting for ACL admission writes nothing. */ +let persistGeneration = 0; /** Read the snapshot. Returns an empty map for a missing, corrupt or stale file. */ export function readPersistedAccountQuotas(now = Date.now()): Map { @@ -102,7 +105,14 @@ export function schedulePersistAccountQuotas( persistTimer = setTimeout(() => { persistTimer = null; persistStartedAt = null; - try { + // Writes under the config root wait out an overlapping optional config-root ACL flight, + // whose inheritance can drop the fresh file's explicit grant (#7815). Rows are read inside + // the admitted callback so a late admission still writes the newest snapshot; a timeout + // skips this best-effort write. + const dir = getConfigDir(); + const generation = persistGeneration; + void withConfigDirHardeningSettled(dir, () => { + if (generation !== persistGeneration) return; const out: Record = {}; for (const [key, quota] of rows()) out[key] = quota; const kiroVerdicts: Record = {}; @@ -110,10 +120,10 @@ export function schedulePersistAccountQuotas( if (key.startsWith("kiro\0") && /^[a-f0-9]{64}$/.test(verdict.identity)) kiroVerdicts[key] = verdict; } const body: DiskFile = { version: 1, rows: out, kiroVerdicts }; - atomicWriteFile(join(getConfigDir(), FILENAME), `${JSON.stringify(body)}\n`); - } catch { + atomicWriteFile(join(dir, FILENAME), `${JSON.stringify(body)}\n`); + }).catch(() => { // Best-effort persistence only. - } + }); }, delay); } @@ -122,4 +132,5 @@ export function cancelPendingAccountQuotaPersist(): void { if (persistTimer) clearTimeout(persistTimer); persistTimer = null; persistStartedAt = null; + persistGeneration += 1; } diff --git a/src/providers/model-discovery-diagnostic.ts b/src/providers/model-discovery-diagnostic.ts new file mode 100644 index 00000000000..ea3623f0c84 --- /dev/null +++ b/src/providers/model-discovery-diagnostic.ts @@ -0,0 +1,4 @@ +/** Safe operator guidance for malformed upstream model lists. */ +export function invalidModelDiscoveryHint(diagnostic?: string): string { + return `${diagnostic ? ` (${diagnostic})` : ""}. A newly added API-key provider can keep its model selection pending while discovery fails; a static models list with liveModels: false avoids discovery for gateways publishing invalid rows.`; +} diff --git a/src/providers/model-discovery.ts b/src/providers/model-discovery.ts index c59c3df7e23..49065c4d74b 100644 --- a/src/providers/model-discovery.ts +++ b/src/providers/model-discovery.ts @@ -47,7 +47,7 @@ export type BoundedDiscoveryJsonResult = export type ProviderModelItemsResult = | { ok: true; items: ProviderModelsApiItem[]; rawCount: number } - | { ok: false; reason: "invalid_shape" | "too_many_models" }; + | { ok: false; reason: "invalid_shape" | "too_many_models"; diagnostic?: string }; export type ModelEnvelopeRowsResult = | { ok: true; rows: unknown[] } @@ -534,12 +534,12 @@ export function extractProviderModelItems( const preferred: ProviderModelsApiItem[] = []; const seen = new Set(); const idField = discovery.spec?.idField ?? "id"; - for (const raw of data) { + for (const [index, raw] of data.entries()) { if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { - return { ok: false, reason: "invalid_shape" }; + return { ok: false, reason: "invalid_shape", diagnostic: `row ${index} is not an object` }; } const id = (raw as Record)[idField]; - if (!isValidModelDiscoveryModelId(id)) return { ok: false, reason: "invalid_shape" }; + if (!isValidModelDiscoveryModelId(id)) return { ok: false, reason: "invalid_shape", diagnostic: `row ${index} has ${typeof id !== "string" ? "a non-string id" : !id.trim() ? "an empty id" : "an invalid id"}` }; const prefix = discovery.spec?.stripIdPrefix; let finalId = id; if (prefix && finalId.startsWith(prefix)) { diff --git a/src/providers/model-rename-fields.ts b/src/providers/model-rename-fields.ts index ea003cedccc..a97202d8bdc 100644 --- a/src/providers/model-rename-fields.ts +++ b/src/providers/model-rename-fields.ts @@ -29,6 +29,7 @@ export const PROVIDER_MODEL_RENAME_ROLES = { supportsServiceTier: "none", fastEnabled: "none", modelSupportsServiceTier: "record", + promoteAdditionalTools: "none", preserveResponsesReasoningContent: "none", preserveResponsesInputItemIds: "none", preserveResponsesMessageMetadata: "none", diff --git a/src/providers/quota.ts b/src/providers/quota.ts index 06292acc709..4526b82a4d1 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -151,8 +151,8 @@ async function maybeFetchProviderQuota( * * No previous snapshot is passed. The observer keeps its own persisted last-seen map, * because `previous` here is bound only when `cache.key === key` and the key digest at - * cacheKeyWithAggregationState includes quota values and updatedAt — so it is empty exactly - * when a reset happened. + * cacheKeyWithAggregationState includes quota values and their freshness — so it is empty + * exactly when a reset happened. * * Account identity is resolved SYNCHRONOUSLY, before any await. Two reasons, both observed: * pool failover rewrites `activeAccountId` during request routing diff --git a/src/providers/quota/report-cache.ts b/src/providers/quota/report-cache.ts index f3fad7a635d..7350263d6cf 100644 --- a/src/providers/quota/report-cache.ts +++ b/src/providers/quota/report-cache.ts @@ -3,7 +3,7 @@ import { providerDecisionWindows } from "../quota-decision-publication"; import type { DecisionQuotaWindow } from "../quota-decision-snapshot"; import { createHash } from "node:crypto"; import { effectiveCodexAuthAccountId, listCodexAuthAccountsSnapshot } from "../../codex/auth-api"; -import { withoutRetiredCodexQuota, type StoredAccountQuota } from "../../codex/quota"; +import { CODEX_EXHAUSTED_USAGE_PERCENT, withoutRetiredCodexQuota, type StoredAccountQuota } from "../../codex/quota"; import { isMainAccountIdentityGenerationLive } from "../../codex/main-account-cache"; import { codexPlanKey } from "../../codex/plan"; import { resolveProviderApiKey } from "../key-store"; @@ -154,16 +154,28 @@ export function hasCodexPoolProvider(config: OcxConfig): boolean { )); } -function quotaSignatureValue(quota: CodexCapacityQuota | null): unknown { +/** + * Signature of one pool account's presentation state. Every proxied Codex response restamps the + * observation, so a raw `updatedAt` in the key stopped the report cache from ever committing + * while traffic flowed (#7956). The key carries the fresh/stale verdict aggregation derives + * from it instead; value changes and freshness transitions still miss the cache. A displayed + * window `updatedAt` can therefore lag a restamp by up to the report cache TTL. The exception is + * an exhausted short window without a reset: its `shortObservedAt` alone decides whether it + * still blocks, over a window as short as that TTL, so that clock stays in the key. + */ +function quotaSignatureValue(quota: CodexCapacityQuota | null, now: number): unknown { if (!quota) return null; + const resetless = !(typeof quota.fiveHourResetAt === "number" && quota.fiveHourResetAt > 0); + const observationDecides = resetless && (quota.fiveHourPercent ?? 0) >= CODEX_EXHAUSTED_USAGE_PERCENT; return { fiveHourPercent: quota.fiveHourPercent, fiveHourResetAt: quota.fiveHourResetAt, + ...(observationDecides ? { shortObservedAt: quota.shortObservedAt } : {}), weeklyPercent: quota.weeklyPercent, weeklyResetAt: quota.weeklyResetAt, monthlyPercent: quota.monthlyPercent, monthlyResetAt: quota.monthlyResetAt, - updatedAt: quota.updatedAt, + fresh: Number.isFinite(quota.updatedAt) && now - quota.updatedAt <= CODEX_CAPACITY_MAX_QUOTA_AGE_MS, customWindows: [...(quota.customWindows ?? [])] .map(window => ({ label: window.label, percent: window.percent, resetAt: window.resetAt })) .sort((a, b) => a.label.localeCompare(b.label)), @@ -204,13 +216,14 @@ export function cacheKeyWithAggregationState( try { const activeId = effectiveCodexAuthAccountId(config); const snapshot = await (prefetchedSnapshot ?? listCodexAuthAccountsSnapshot(config, false)); + const now = Date.now(); const rows = snapshot.accounts.map(account => ({ isMain: account.isMain, active: account.id === activeId, plan: codexPlanKey(account.plan) ?? null, paused: account.paused, needsReauth: account.needsReauth === true, - quota: quotaSignatureValue(providerQuotaFromCodexQuota(account.quota)), + quota: quotaSignatureValue(providerQuotaFromCodexQuota(account.quota), now), })); const canonicalRows = rows.map(row => JSON.stringify(row)).sort(); const digest = createHash("sha256").update(JSON.stringify(canonicalRows)).digest("hex").slice(0, 24); diff --git a/src/providers/reasoning-metadata.ts b/src/providers/reasoning-metadata.ts index 45f83ebc65c..a58a93889ed 100644 --- a/src/providers/reasoning-metadata.ts +++ b/src/providers/reasoning-metadata.ts @@ -25,7 +25,7 @@ import { join } from "node:path"; // already imports. Going through the ../config barrel closes a cycle back into account-namespaces.ts // and leaves COMBO_NAMESPACE in its temporal dead zone for entry points that start at combos/types.ts. import { atomicWriteFile } from "../config/atomic-write"; -import { getConfigDir } from "../config/paths"; +import { getConfigDir, withConfigDirHardeningSettled } from "../config/paths"; import type { OcxProviderConfig } from "../types"; import { resolveProviderApiKey } from "./api-key-resolve"; @@ -96,7 +96,9 @@ interface SupportSnapshot { let snapshotMemo: MetadataSnapshot | null | undefined; let supportMemo: Map | undefined; let persistTimer: ReturnType | null = null; -let refreshInFlight: Promise | null = null; +let refreshInFlight: Promise | null = null; +/** Bumped by reset so a fired support write still waiting for ACL admission writes nothing. */ +let persistGeneration = 0; /** Test seam: drop the memoised snapshot/support caches so a suite can drive the load paths. */ export function resetReasoningMetadataCachesForTests(): void { @@ -107,6 +109,7 @@ export function resetReasoningMetadataCachesForTests(): void { persistTimer = null; } refreshInFlight = null; + persistGeneration += 1; } function readJsonFile(filename: string): T | null { @@ -375,7 +378,12 @@ export function recordUnsupportedReasoningEffort( if (persistTimer) clearTimeout(persistTimer); persistTimer = setTimeout(() => { persistTimer = null; - try { + // Admitted after an overlapping optional config-root ACL flight (#7815); rows are read + // inside the callback, and a timed-out admission skips this best-effort write. + const dir = getConfigDir(); + const generation = persistGeneration; + void withConfigDirHardeningSettled(dir, () => { + if (generation !== persistGeneration) return; const out: SupportSnapshot["rows"] = {}; for (const [rowKey, at] of rows) { const evidenceText = supportEvidence.get(rowKey); @@ -385,30 +393,33 @@ export function recordUnsupportedReasoningEffort( ...(evidenceText ? { evidence: evidenceText } : {}), }; } - atomicWriteFile(join(getConfigDir(), SUPPORT_FILENAME), JSON.stringify({ version: 2, rows: out }) + "\n"); - } catch { + atomicWriteFile(join(dir, SUPPORT_FILENAME), JSON.stringify({ version: 2, rows: out }) + "\n"); + }).catch(() => { // Best-effort persistence only. - } + }); }, PERSIST_DEBOUNCE_MS); return true; } -/** Test seam: flush a pending support write so a script sees the snapshot immediately. */ -export function flushReasoningSupportCache(): void { +/** Test seam: flush a pending support write, admitted like the timer write (#7815). */ +export async function flushReasoningSupportCache(): Promise { if (!persistTimer) return; clearTimeout(persistTimer); persistTimer = null; - try { + const dir = getConfigDir(); + const generation = persistGeneration; + await withConfigDirHardeningSettled(dir, () => { + if (generation !== persistGeneration) return; const rows = loadSupport(); const out: SupportSnapshot["rows"] = {}; for (const [rowKey, at] of rows) { const evidenceText = supportEvidence.get(rowKey); out[rowKey] = { effort: JSON.parse(rowKey)[3] ?? "", at, ...(evidenceText ? { evidence: evidenceText } : {}) }; } - atomicWriteFile(join(getConfigDir(), SUPPORT_FILENAME), JSON.stringify({ version: 2, rows: out }) + "\n"); - } catch { + atomicWriteFile(join(dir, SUPPORT_FILENAME), JSON.stringify({ version: 2, rows: out }) + "\n"); + }).catch(() => { // Best-effort persistence only. - } + }); } /** @@ -521,7 +532,8 @@ export async function refreshReasoningMetadata(options: { force?: boolean; waitM return { ok: true, reason: "fresh" }; } if (refreshInFlight) { - return waitForRefresh(refreshInFlight.then(() => ({ ok: true, reason: "coalesced" })), options.waitMs); + // A joined caller learns the shared outcome: a refused or failed refresh is not a success. + return waitForRefresh(refreshInFlight.then(outcome => outcome.ok ? { ok: true, reason: "coalesced" } : outcome), options.waitMs); } const job = (async () => { const response = await fetch(SOURCE_URL, { @@ -565,15 +577,19 @@ export async function refreshReasoningMetadata(options: { force?: boolean; waitM providers[providerKey] = out; } const next: MetadataSnapshot = { version: 2, fetchedAt: Date.now(), source: SOURCE_URL, providers, apis }; - atomicWriteFile(join(getConfigDir(), FILENAME), JSON.stringify(next) + "\n"); + // The last await is the network read; publication waits out an overlapping optional + // config-root ACL flight (#7815) and a timed-out admission fails this refresh unpublished. + const dir = getConfigDir(); + await withConfigDirHardeningSettled(dir, () => atomicWriteFile(join(dir, FILENAME), JSON.stringify(next) + "\n")); snapshotMemo = next; return { ok: true, reason: "refreshed", providers: Object.keys(providers).length, models }; })(); - refreshInFlight = job.catch(() => undefined).finally(() => { refreshInFlight = null; }); const settled = job.catch((error): RefreshOutcome => ({ ok: false, reason: error instanceof Error ? error.message : String(error), })); + const shared = settled.finally(() => { if (refreshInFlight === shared) refreshInFlight = null; }); + refreshInFlight = shared; return waitForRefresh(settled, options.waitMs); } diff --git a/src/remote-control/workspace-codex-runtime.ts b/src/remote-control/workspace-codex-runtime.ts index 9962477bc93..df2bd7bb3c9 100644 --- a/src/remote-control/workspace-codex-runtime.ts +++ b/src/remote-control/workspace-codex-runtime.ts @@ -7,7 +7,7 @@ import { type CodexExecBoundaryAsyncDeps, } from "../codex/exec-boundary"; import { - beginCodexCliSelectionParentWrite, + beginCodexCliSelectionParentWriteAsync, CodexCliSelectionMutationBusyError, } from "../codex/cli-update-selection-gate"; import { remoteWorkspaceThreadStartParams } from "./workspace-coordinator"; @@ -371,7 +371,7 @@ export class CodexRemoteWorkspaceRuntimeFactory implements RemoteWorkspaceRuntim // installer termination/readback; a new session must not appear inside that // window. Busy is the same explicit pre-spawn failure path as a spawn // throw, so the bridge and isolation are reclaimed identically. - const selectionWrite = beginCodexCliSelectionParentWrite(); + const selectionWrite = await beginCodexCliSelectionParentWriteAsync(); if (!selectionWrite.entered) { // Both teardown steps are attempted before the refusal leaves: the // ordered runner keeps a rejected bridge stop from skipping isolation diff --git a/src/responses/custom-tool-compat.ts b/src/responses/custom-tool-compat.ts index 4b15bc9f9ff..30a894f06c2 100644 --- a/src/responses/custom-tool-compat.ts +++ b/src/responses/custom-tool-compat.ts @@ -232,7 +232,8 @@ function rewriteForUpstream( } let changed = false; - const next: Record = {}; + // Recursive tool lowering must retain the input item's historical/live symbol marker. + const next: Record = { ...value }; for (const [key, entry] of Object.entries(value)) { const rewritten = rewriteForUpstream(entry, names, callIds); next[key] = rewritten; diff --git a/src/responses/item-provenance.ts b/src/responses/item-provenance.ts new file mode 100644 index 00000000000..4cf5b7c365f --- /dev/null +++ b/src/responses/item-provenance.ts @@ -0,0 +1,79 @@ +export const REPLAY_HISTORICAL_ITEM = Symbol.for("ocx.responses.replayHistoricalItem"); + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Mark an input item object as belonging to the historical replay prefix. + * Uses an enumerable symbol so it is preserved across shallow object spreads (`{ ...item }`), + * but completely ignored by `JSON.stringify` and `Object.keys`. + */ +export function markHistoricalItem(item: T): T { + Object.defineProperty(item, REPLAY_HISTORICAL_ITEM, { + value: true, + enumerable: true, + configurable: true, + writable: true, + }); + return item; +} + +/** + * Check whether an input item originated in the historical replay. + */ +export function isHistoricalItem(item: unknown): boolean { + return ( + typeof item === "object" + && item !== null + && (item as Record)[REPLAY_HISTORICAL_ITEM] === true + ); +} + +/** A live marker survives even when a rewrite removes every historical item. */ +export function hasItemProvenance(item: unknown): boolean { + return typeof item === "object" && item !== null + && typeof (item as Record)[REPLAY_HISTORICAL_ITEM] === "boolean"; +} + +/** Replacement and synthesized items inherit the source's historical or live tag. */ +export function copyItemProvenance(source: unknown, target: T): T { + if (hasItemProvenance(source)) { + Object.assign(target, { [REPLAY_HISTORICAL_ITEM]: isHistoricalItem(source) }); + } + return target; +} + +/** + * Tag both historical and live items so an empty replay prefix remains distinguishable + * from an input that has never been tagged. Input that already carries provenance keeps it: + * an earlier rewrite (Zed lifting a replayed wrapper) may have shifted items past the + * numeric prefix, so re-tagging by index would mislabel current-turn items as history. + */ +export function tagHistoricalInputItems(input: unknown[], prefixLength: number): unknown[] { + if (!Array.isArray(input)) return input; + if (input.some(hasItemProvenance)) return input; + const prefix = Math.min(input.length, Math.max(0, Math.trunc(prefixLength))); + if (prefix <= 0) return input; + return input.map((item, index) => { + if (isPlainObject(item)) { + return { ...item, [REPLAY_HISTORICAL_ITEM]: index < prefix }; + } + return item; + }); +} + +/** + * Compute the current replay prefix length dynamically. + * If items carry provenance markers, the boundary is the index of the first non-historical item. + * Otherwise, falls back to the provided fallback prefix length. + */ +export function computeReplayPrefixLength(input: unknown[], fallback = 0): number { + if (!Array.isArray(input)) return fallback; + const hasProvenance = input.some(hasItemProvenance); + if (!hasProvenance) { + return Math.min(input.length, Math.max(0, Math.trunc(fallback))); + } + const firstLiveIndex = input.findIndex(item => !isHistoricalItem(item)); + return firstLiveIndex === -1 ? input.length : firstLiveIndex; +} diff --git a/src/responses/namespace-tool-compat.ts b/src/responses/namespace-tool-compat.ts index 24b26e8a2ef..0c24ac3e87e 100644 --- a/src/responses/namespace-tool-compat.ts +++ b/src/responses/namespace-tool-compat.ts @@ -1,6 +1,7 @@ import { dottedToolName, namespacedToolName } from "../types"; import { collectResponsesToolGroups } from "./tool-groups"; import { collectAmbiguousDottedAliases, dottedAliasIsUnambiguous } from "./tool-name-aliases"; +import { hasItemProvenance, isHistoricalItem } from "./item-provenance"; export interface RoutedNamespaceToolIdentity { namespace: string; @@ -344,13 +345,25 @@ function rewriteInputItem(item: unknown, plan: NamespaceRewritePlan, emitted: Se export function rewriteRoutedNamespaceToolsForUpstream( body: unknown, convertedCustomToolNames?: ReadonlySet, + replayPrefixLength?: number, ): { body: unknown; aliases: Map; } { if (!isPlainObject(body)) return { body, aliases: new Map() }; - const groups = collectResponsesToolGroups(body); + const currentTurnStart = Number.isFinite(replayPrefixLength) + ? Math.max(0, Math.trunc(replayPrefixLength!)) + : 0; + const hasProvenance = Array.isArray(body.input) && body.input.some(hasItemProvenance); + const isHistorical = (item: unknown, index: number) => + hasProvenance ? isHistoricalItem(item) : index < currentTurnStart; + const groups = collectResponsesToolGroups(Array.isArray(body.input) + ? { ...body, input: body.input.filter((item, index) => !isHistorical(item, index)) } + : body); const plan = buildRewritePlan(groups); + const historicalPlan = buildRewritePlan(collectResponsesToolGroups({ + input: Array.isArray(body.input) ? body.input.filter(isHistorical) : [], + })); // Deliberately not gated on the plan being non-empty: a turn whose catalog is absent can still // replay call items carrying a private `namespace`. @@ -360,8 +373,13 @@ export function rewriteRoutedNamespaceToolsForUpstream( let input = body.input; if (Array.isArray(body.input)) { let inputChanged = false; - const rewrittenInput = body.input.map(item => { - const next = rewriteInputItem(item, plan, emitted); + const historicalEmitted = new Set(); + const rewrittenInput = body.input.map((item, index) => { + const historicalCatalog = isHistorical(item, index) && isPlainObject(item) && item.type === "additional_tools"; + const itemEmitted = historicalCatalog + ? historicalEmitted + : emitted; + const next = rewriteInputItem(item, historicalCatalog ? historicalPlan : plan, itemEmitted); if (next !== item) inputChanged = true; return next; }); diff --git a/src/responses/parser-tools.ts b/src/responses/parser-tools.ts index 763b061c8f6..ee0268d524c 100644 --- a/src/responses/parser-tools.ts +++ b/src/responses/parser-tools.ts @@ -16,6 +16,7 @@ export function mapToolChoice(value: unknown): OcxRequestOptions["toolChoice"] { if (t === "image_generation" || t === "image_gen") { return { name: IMAGE_GEN_TOOL_NAME }; } + if (t === "tool_search") return { name: "tool_search" }; if (t === "allowed_tools" && Array.isArray(value.tools)) { const names = value.tools .map(allowedToolName) diff --git a/src/responses/schema.ts b/src/responses/schema.ts index d3fac2f8632..213e443d2d4 100644 --- a/src/responses/schema.ts +++ b/src/responses/schema.ts @@ -162,12 +162,15 @@ const builtinToolSchema = z.object({ type: z.string() }).loose(); */ export const HOSTED_TOOL_TYPES = [ "web_search", "web_search_preview", "file_search", "computer_use_preview", - "code_interpreter", "image_generation", "mcp", + "code_interpreter", "image_generation", "mcp", "tool_search", "computer_use", "image_gen", ] as const; const hostedToolType = z.enum(HOSTED_TOOL_TYPES); -const allowedToolEntrySchema = z.object({ type: z.string(), name: z.string().optional() }); +const allowedToolEntrySchema = z.union([ + z.object({ type: z.literal("mcp"), server_label: z.string().min(1), name: z.string().nullable().optional() }), + z.object({ type: z.string(), name: z.string().optional() }), +]); export const toolChoiceSchema = z.union([ z.literal("auto"), diff --git a/src/router.ts b/src/router.ts index d3939298e05..b9f4b85ae99 100644 --- a/src/router.ts +++ b/src/router.ts @@ -30,7 +30,7 @@ import { LEGACY_OPENAI_MULTI_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID, } from "./providers/openai-tiers"; -import { decodeRoutedModelIdOrThrow, encodeRoutedModelId } from "./providers/slug-codec"; +import { decodeRoutedModelId, decodeRoutedModelIdOrThrow, encodeRoutedModelId } from "./providers/slug-codec"; import { effectiveProviderAliasDecision, resolveModelAlias } from "./providers/default-aliases"; import { resolveBlockedModelRedirect } from "./lib/shadow-call"; import { workBuddyCatalogIdentity } from "./providers/workbuddy"; @@ -200,6 +200,20 @@ export function knownModelIdsForProvider( * (never a caller-supplied string), so the log carries no request content. */ const compactionFallbackWarnings = new Set(); +/** + * Last-resort decode for a direct provider-qualified slug the live roster cannot resolve yet. + * Retained ids win (an encoded slash-valued retained id included), then the operator-declared + * modelDisplayNames keys. Neither list feeds alias generation. + */ +function decodeDisplayNameFallback(prov: OcxProviderConfig, decoded: string): string { + const retained = prov.retainModels ?? []; + // Any retained claim, even an ambiguous one the codec refuses to guess, ends the fallback. + if (retained.some(id => id === decoded || encodeRoutedModelId(id) === decoded)) { + return decodeRoutedModelId(decoded, retained); + } + return decodeRoutedModelId(decoded, Object.keys(prov.modelDisplayNames ?? {})); +} + function warnCompactionDefaultProviderFallbackOnce(providerName: string): void { if (compactionFallbackWarnings.has(providerName)) return; compactionFallbackWarnings.add(providerName); @@ -583,7 +597,12 @@ function routeModelInternal( const decoded = decodeRoutedModelIdOrThrow(requestedModel, known); const nativeModel = known.includes(decoded) ? decoded - : resolveModelAlias(config, prov, known, requestedModel) ?? decoded; + : resolveModelAlias(config, prov, known, requestedModel) + // Last resort for direct requests while the live roster is cold: display-name keys are + // operator-declared native ids, consulted only here so they never claim aliases or shadow + // known or retained ids. Concrete policy/combo/redirect targets (bypassCombos) keep the + // exact model their evaluator selected, so evidence and eligibility describe what is sent. + ?? (bypassCombos ? decoded : decodeDisplayNameFallback(prov, decoded)); return routeResult( config, provName, diff --git a/src/routing/analytics.ts b/src/routing/analytics.ts index ab919d88433..220a65e70f5 100644 --- a/src/routing/analytics.ts +++ b/src/routing/analytics.ts @@ -13,7 +13,7 @@ import { normalizePersistedUsageRow, type PersistedUsageEntry } from "../usage/log"; import { computeEntryCost } from "../usage/summary"; -import { openRequestHistoryIndex, requestHistoryDb } from "./history/indexer"; +import { withRequestHistoryRead } from "./history/indexer"; export const ANALYTICS_MAX_ROWS = 50_000; /** Default row cap for the management API (full cap remains available via `limit`). */ @@ -168,8 +168,6 @@ export async function computeRoutingAnalytics( filters: RoutingAnalyticsFilters, options: { maxRows?: number } = {}, ): Promise { - await openRequestHistoryIndex(); - const handle = requestHistoryDb(); const maxRows = Math.min( Math.max(1, Math.trunc(options.maxRows ?? ANALYTICS_MAX_ROWS)), ANALYTICS_MAX_ROWS, @@ -189,7 +187,7 @@ export async function computeRoutingAnalytics( if (filters.to !== undefined) add("timestamp <= ?", filters.to); const whereSql = where.length > 0 ? ` WHERE ${where.join(" AND ")}` : ""; - const rows = handle.query( + const rows = await withRequestHistoryRead(handle => handle.query( `SELECT provider, model, api_key_id AS apiKeyId, profile_id AS profileId, profile_revision AS profileRevision, status, duration_ms AS durationMs, first_output_ms AS firstOutputMs, @@ -197,7 +195,7 @@ export async function computeRoutingAnalytics( usage_status AS usageStatus, usage_json AS usageJson, attempt_count AS attemptCount, fallback, row_json AS rowJson FROM requests${whereSql} ORDER BY timestamp DESC LIMIT ?`, - ).all(...values, maxRows + 1) as ScannedRow[]; + ).all(...values, maxRows + 1) as ScannedRow[]); const scanned = rows.slice(0, maxRows); const historyTruncated = rows.length > maxRows; diff --git a/src/routing/health.ts b/src/routing/health.ts index ceab2e1b9a1..af4b20f5e42 100644 --- a/src/routing/health.ts +++ b/src/routing/health.ts @@ -17,7 +17,7 @@ */ import type { OcxConfig } from "../types"; -import { openRequestHistoryIndexSync, requestHistoryDb } from "./history/indexer"; +import { openRequestHistoryIndexSync, reportRequestHistoryCorruption, requestHistoryDb } from "./history/indexer"; import { OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers"; import { getCodexAccountCooldownUntil, @@ -251,9 +251,10 @@ function computeHistoricalHealthEvidence( input: Pick, now: number, ): HistoricalHealthEvidence { + let handle: ReturnType | undefined; try { openRequestHistoryIndexSync(); - const handle = requestHistoryDb(); + handle = requestHistoryDb(); const where: string[] = ["provider = ?", "model = ?", "timestamp >= ?"]; const values: Array = [input.provider, input.model, now - HEALTH_WINDOW_MS]; if (input.accountRef) { @@ -341,7 +342,8 @@ function computeHistoricalHealthEvidence( out.recencyWeight = decayWeight(samples[0]!.timestamp, now); } return out; - } catch { + } catch (error) { + if (handle) reportRequestHistoryCorruption(error, handle); /* index unreadable: evidence stays unknown */ return {}; } diff --git a/src/routing/history/build-file.ts b/src/routing/history/build-file.ts index 772697e990e..de443b549e2 100644 --- a/src/routing/history/build-file.ts +++ b/src/routing/history/build-file.ts @@ -79,6 +79,12 @@ export function buildIndexFile(buildPath: string, reason: string, revision: Usag expected: revision, endOffset: Number(revision.size), }); + // Validate before publication while still off the serving thread. The + // published handle can then be trusted until its periodic worker check. + const check = build.query("PRAGMA quick_check").get() as { quick_check?: string } | undefined; + if (check?.quick_check !== "ok") { + throw Object.assign(new Error("rebuilt history index failed integrity check"), { code: "SQLITE_CORRUPT" }); + } // The serving thread opens the published file in WAL mode; switching here // keeps that first open from rewriting the database header. build.exec("PRAGMA journal_mode = WAL"); diff --git a/src/routing/history/indexer.ts b/src/routing/history/indexer.ts index 342e98fab4b..68413390ab4 100644 --- a/src/routing/history/indexer.ts +++ b/src/routing/history/indexer.ts @@ -3,7 +3,7 @@ * * `usage.jsonl` stays canonical; this module maintains a rebuildable SQLite * projection (ADR-1, ADR-8). On every open/query it verifies schema version, - * file identity, integrity, and byte offset, then appends whatever complete + * file identity and byte offset, then appends whatever complete * JSONL rows arrived since the last index. Missing/corrupt/stale index or a * replaced/truncated source triggers an automatic full rebuild; canonical * history is never touched. @@ -12,6 +12,8 @@ * runs on a worker thread (#6990): the worker writes a private build file and * this thread swaps it in once complete. Async readers wait for it; the * routing-time sync entry never waits and throws `RequestHistoryIndexBusyError`. + * Full integrity scans run on that worker at open and periodically while in + * use, never synchronously on the serving thread (#7966). */ import { Database } from "bun:sqlite"; @@ -48,6 +50,8 @@ import { } from "./ingest"; import { buildFilePath, removeBuildFiles } from "./build-file"; import { RebuildWorkerJob } from "./rebuild-job"; +import { RequestHistoryIntegrityMonitor, type RequestHistoryIntegrityOptions } from "./integrity"; +import { isHistoryIndexCorruption } from "./sqlite-errors"; export { REQUEST_HISTORY_INSERT_BATCH, @@ -138,9 +142,15 @@ class RebuildHandedOff { constructor(readonly rebuild: WorkerRebuild) {} } +/** An initial integrity check, or a scanner draining before index replacement. */ +class IntegrityHandedOff { + constructor(readonly promise: Promise) {} +} + let db: Database | null = null; let dbPath = ""; let openPromise: Promise | null = null; +let manualRebuild: Promise | null = null; let workerRebuild: WorkerRebuild | null = null; let workerFailure: { at: number; error: unknown } | null = null; let workerRebuildMinBytes = REQUEST_HISTORY_WORKER_REBUILD_MIN_BYTES; @@ -148,6 +158,33 @@ let maxAsyncRefreshBuilds = MAX_ASYNC_REFRESH_BUILDS; let buildSequence = 0; // Bumped by closeRequestHistoryIndex, so a rebuild finishing after a close is discarded. let indexLifetime = 0; +let integrity = new RequestHistoryIntegrityMonitor(); +let integrityDrain: Promise | null = null; +let corruptHandle: Database | null = null; +let validatedBuildPath: string | null = null; + +/** Test seam: replace integrity scheduling without leaving a scanner on an old file. */ +export function setRequestHistoryIntegrityOptionsForTests(options?: RequestHistoryIntegrityOptions): void { + closeRequestHistoryIndex(); + integrity = new RequestHistoryIntegrityMonitor(options); +} + +function stopIntegrityScan(): Promise | null { + const pending = integrity.quiesce(); + if (pending && !integrityDrain) { + const drained = pending.finally(() => { + if (integrityDrain === drained) integrityDrain = null; + }); + integrityDrain = drained; + } + return integrityDrain; +} + +/** Never delete/rename a database that a cancelled scanner may still hold on Windows. */ +function prepareIndexReplacement(): void { + const pending = stopIntegrityScan(); + if (pending) throw new IntegrityHandedOff(pending); +} /** Test seam: move the worker threshold; `undefined` restores the default. */ export function setRequestHistoryWorkerRebuildMinBytesForTests(bytes: number | undefined): void { @@ -210,15 +247,6 @@ export function sourceIdentityMatches(dbHandle: Database, revision: UsageLogRevi && storedBirthtimeMs === Number(revision.birthtimeMs); } -function isHealthy(dbHandle: Database): boolean { - try { - const row = dbHandle.query("PRAGMA quick_check").get() as { quick_check?: string } | undefined; - return row?.quick_check === "ok"; - } catch { - return false; - } -} - /** Remove the index file and its WAL side files. */ function removeIndexFiles(path: string): void { // Windows: a partially-opened handle from a failed `new Database` can hold @@ -238,6 +266,7 @@ function removeIndexFiles(path: string): void { } function destroyAndRecreate(path: string, reason: string): Database { + prepareIndexReplacement(); if (db) { try { db.close(); } catch { /* already closed */ } db = null; @@ -249,10 +278,13 @@ function destroyAndRecreate(path: string, reason: string): Database { resetAndCreateSchema(fresh); setMeta(fresh, HISTORY_META_KEYS.lastError, reason); db = fresh; + corruptHandle = null; + validatedBuildPath = null; return fresh; } function openIndexDb(): Database { + if (integrityDrain) throw new IntegrityHandedOff(integrityDrain); const path = indexDbPath(); if (db) return db; let handle: Database | undefined; @@ -270,45 +302,64 @@ function openIndexDb(): Database { setMeta(handle, HISTORY_META_KEYS.builtAtMs, Date.now()); setMeta(handle, HISTORY_META_KEYS.lastError, "created"); } - } catch { + } catch (error) { // A partially-opened handle on a corrupt file can hold the OS lock on // Windows; close it before the destructive recreate. if (handle) { try { handle.close(); } catch { /* already unusable */ } } + if (!isHistoryIndexCorruption(error)) throw error; handle = destroyAndRecreate(path, "unreadable database recreated"); } dbPath = path; db = handle; + if (validatedBuildPath === path) { + integrity.acceptRebuilt(handle, path); + validatedBuildPath = null; + } return handle; } function ensureSchemaAndIdentity(dbHandle: Database): "ready" | "rebuilt" { try { + if (corruptHandle === dbHandle) { + destroyAndRecreate(dbPath, "integrity check failed; index rebuilt"); + return "rebuilt"; + } const storedVersion = metaValue(dbHandle, HISTORY_META_KEYS.schemaVersion); if (storedVersion !== String(HISTORY_SCHEMA_VERSION)) { destroyAndRecreate(dbPath, `schema version ${storedVersion ?? "missing"} -> ${HISTORY_SCHEMA_VERSION}`); return "rebuilt"; } - if (!isHealthy(dbHandle)) { - destroyAndRecreate(dbPath, "integrity check failed; index rebuilt"); - return "rebuilt"; - } const revision = sourceIdentity(); if (!sourceIdentityMatches(dbHandle, revision)) { destroyAndRecreate(dbPath, "source identity changed; index rebuilt"); return "rebuilt"; } + const check = integrity.inspect(dbHandle, dbPath); + if (check.state === "pending") throw new IntegrityHandedOff(check.promise); + if (check.state === "unavailable") { + throw new RequestHistoryIndexBusyError("request-history integrity check unavailable"); + } + if (check.state === "corrupt") { + corruptHandle = dbHandle; + destroyAndRecreate(dbPath, "integrity check failed; index rebuilt"); + return "rebuilt"; + } return "ready"; - } catch { + } catch (error) { + if (!isHistoryIndexCorruption(error)) throw error; // A file that opens but is not actually SQLite (or is mid-corruption) // throws on the first statement; treat it as corrupt and rebuild. + corruptHandle = dbHandle; + integrity.markCorrupt(dbHandle); destroyAndRecreate(dbPath, "index unreadable; rebuilt"); return "rebuilt"; } } function fullRebuild(dbHandle: Database, reason: string): void { + prepareIndexReplacement(); resetAndCreateSchema(dbHandle); setMeta(dbHandle, HISTORY_META_KEYS.lastError, reason); const path = usageLogPath(); @@ -319,6 +370,8 @@ function fullRebuild(dbHandle: Database, reason: string): void { setMeta(dbHandle, HISTORY_META_KEYS.indexedOffset, 0); setMeta(dbHandle, HISTORY_META_KEYS.indexedRows, 0); setMeta(dbHandle, HISTORY_META_KEYS.builtAtMs, Date.now()); + integrity.acceptRebuilt(dbHandle, dbPath); + corruptHandle = null; return; } const inserted = ingestSourceTail(dbHandle, path, 0); @@ -326,6 +379,8 @@ function fullRebuild(dbHandle: Database, reason: string): void { setMeta(dbHandle, HISTORY_META_KEYS.indexedRows, inserted); setMeta(dbHandle, HISTORY_META_KEYS.indexedOffset, Math.max(current.indexedOffset, 0)); setMeta(dbHandle, HISTORY_META_KEYS.builtAtMs, Date.now()); + integrity.acceptRebuilt(dbHandle, dbPath); + corruptHandle = null; } /** @@ -372,12 +427,15 @@ function startWorkerRebuild(reason: string, revision: UsageLogRevision): WorkerR /** Swap a complete worker build in for the current index file. */ function installBuild(buildPath: string, indexPath: string): void { + prepareIndexReplacement(); if (db) { try { db.close(); } catch { /* already closed */ } db = null; } removeIndexFiles(indexPath); renameSync(buildPath, indexPath); + corruptHandle = null; + validatedBuildPath = indexPath; } /** @@ -389,6 +447,7 @@ type RefreshCaller = "routing" | "reader"; /** Full rebuild, on the worker when the ledger is large enough to stall the event loop. */ function rebuildFromLedger(handle: Database, reason: string, caller: RefreshCaller): void { + prepareIndexReplacement(); const revision = sourceIdentity(); if (revision !== null && Number(revision.size) >= workerRebuildMinBytes) { if (workerCoolingDown()) { @@ -405,7 +464,7 @@ function rebuildFromLedger(handle: Database, reason: string, caller: RefreshCall fullRebuild(handle, reason); } -function refreshLockedSync(caller: RefreshCaller): RequestHistoryIndexMeta { +function refreshOnceLockedSync(caller: RefreshCaller): RequestHistoryIndexMeta { if (workerRebuild) throw new RebuildHandedOff(workerRebuild); openIndexDb(); const state = ensureSchemaAndIdentity(db!); @@ -436,27 +495,55 @@ function refreshLockedSync(caller: RefreshCaller): RequestHistoryIndexMeta { return metaFor(handle); } +function refreshLockedSync(caller: RefreshCaller): RequestHistoryIndexMeta { + try { + return refreshOnceLockedSync(caller); + } catch (error) { + // Metadata reads and incremental writes can encounter damage even between + // scheduled checks. Operational failures (busy, locked, I/O, full) propagate. + if (!isHistoryIndexCorruption(error)) throw error; + if (db) { + corruptHandle = db; + integrity.markCorrupt(db); + } + const handle = destroyAndRecreate(dbPath, "SQLite corruption; index rebuilt"); + rebuildFromLedger(handle, "SQLite corruption; index rebuilt", caller); + return metaFor(handle); + } +} + /** * Synchronous refresh for routing-time evidence reads (RI-06+). Never waits * for a worker rebuild: while one runs (this call may start it), throws * `RequestHistoryIndexBusyError`, which health scoring reads as unknown evidence. */ export function openRequestHistoryIndexSync(): RequestHistoryIndexMeta { + if (manualRebuild) throw new RequestHistoryIndexBusyError(); try { return refreshLockedSync("routing"); } catch (error) { - if (error instanceof RebuildHandedOff) throw new RequestHistoryIndexBusyError(); + if (error instanceof RebuildHandedOff || error instanceof IntegrityHandedOff) throw new RequestHistoryIndexBusyError(); throw error; } } -async function refreshWaitingForWorker(): Promise { +async function refreshWaitingForWorker(fromManualRebuild = false): Promise { const lifetime = indexLifetime; for (let builds = 0; ; ) { + if (manualRebuild && !fromManualRebuild) { + await manualRebuild; + if (lifetime !== indexLifetime) throw new RequestHistoryIndexClosedError(); + continue; + } let handedOff: RebuildHandedOff; try { return refreshLockedSync("reader"); } catch (error) { + if (error instanceof IntegrityHandedOff) { + await error.promise; + if (lifetime !== indexLifetime) throw new RequestHistoryIndexClosedError(); + continue; + } if (!(error instanceof RebuildHandedOff)) throw error; handedOff = error; } @@ -480,15 +567,19 @@ async function refreshWaitingForWorker(): Promise { */ export function openRequestHistoryIndex(): Promise { if (!openPromise) { - openPromise = refreshWaitingForWorker().finally(() => { - openPromise = null; + const pending = refreshWaitingForWorker().finally(() => { + if (openPromise === pending) openPromise = null; }); + openPromise = pending; } return openPromise; } export function closeRequestHistoryIndex(): void { indexLifetime += 1; + stopIntegrityScan(); + corruptHandle = null; + validatedBuildPath = null; const rebuild = workerRebuild; workerRebuild = null; workerFailure = null; @@ -499,21 +590,36 @@ export function closeRequestHistoryIndex(): void { db = null; } openPromise = null; + manualRebuild = null; } /** Force a full rebuild from the canonical ledger (CLI / tests). */ -export async function rebuildRequestHistoryIndex(): Promise { +export function rebuildRequestHistoryIndex(): Promise { + if (!manualRebuild) { + const pending = rebuildManually(indexLifetime).finally(() => { + if (manualRebuild === pending) manualRebuild = null; + }); + manualRebuild = pending; + } + return manualRebuild; +} + +async function rebuildManually(lifetime: number): Promise { + const draining = stopIntegrityScan(); + if (draining) await draining; + if (lifetime !== indexLifetime) throw new RequestHistoryIndexClosedError(); while (workerRebuild) await workerRebuild.promise.catch(() => undefined); + if (lifetime !== indexLifetime) throw new RequestHistoryIndexClosedError(); const handle = openIndexDb(); const revision = sourceIdentity(); if (revision !== null && Number(revision.size) >= workerRebuildMinBytes) { workerFailure = null; const rebuild = startWorkerRebuild("manual rebuild requested", revision); if (rebuild) { - const lifetime = indexLifetime; try { await rebuild.promise; - return openRequestHistoryIndex(); + if (lifetime !== indexLifetime) throw new RequestHistoryIndexClosedError(); + return refreshWaitingForWorker(true); } catch (error) { // A close is final; any other worker failure falls back to the inline rebuild below. if (lifetime !== indexLifetime || error instanceof RequestHistoryIndexClosedError) throw error; @@ -575,6 +681,29 @@ function requireDb(): Database { return handle; } +/** Invalidate only the live generation, including errors from routing's synchronous reads. */ +export function reportRequestHistoryCorruption(error: unknown, handle: Database): boolean { + if (!isHistoryIndexCorruption(error)) return false; + if (db === handle) { + corruptHandle = handle; + integrity.markCorrupt(handle); + } + return true; +} + +/** Read-only operations may retry once after repair; ordinary SQLite failures propagate. */ +export async function withRequestHistoryRead(read: (handle: Database, meta: RequestHistoryIndexMeta) => T): Promise { + for (let attempt = 0; ; attempt++) { + const meta = await openRequestHistoryIndex(); + const handle = requireDb(); + try { + return read(handle, meta); + } catch (error) { + if (!reportRequestHistoryCorruption(error, handle) || attempt > 0) throw error; + } + } +} + function hydrateRow(row: HistoryQueryRow | undefined): PersistedUsageEntry | null { if (!row) return null; try { @@ -593,7 +722,6 @@ export async function queryRequestHistory( rawCursor: string | null | undefined, pageSize: number | undefined, ): Promise { - const meta = await openRequestHistoryIndex(); const cursor = decodeHistoryCursor(rawCursor); if (rawCursor !== null && rawCursor !== undefined && cursor === null) { throw new InvalidCursorError(); @@ -602,8 +730,9 @@ export async function queryRequestHistory( Math.max(1, Math.trunc(pageSize ?? REQUEST_HISTORY_DEFAULT_PAGE_SIZE)), REQUEST_HISTORY_MAX_PAGE_SIZE, ); - const handle = requireDb(); - const { rows, fetched } = queryRows(handle, filters, cursor, limit); + const { rows, fetched, meta } = await withRequestHistoryRead((handle, meta) => ({ + ...queryRows(handle, filters, cursor, limit), meta, + })); const hasMore = fetched > limit; const pageRows = rows.slice(0, limit); const entries: PersistedUsageEntry[] = []; @@ -620,11 +749,9 @@ export async function queryRequestHistory( } export async function requestHistoryRowById(requestId: string): Promise { - await openRequestHistoryIndex(); - const handle = requireDb(); - const row = handle.query("SELECT row_json FROM requests WHERE request_id = ?").get(requestId) as + const row = await withRequestHistoryRead(handle => handle.query("SELECT row_json FROM requests WHERE request_id = ?").get(requestId) as | { row_json: string } - | undefined; + | undefined); return hydrateRow(row); } diff --git a/src/routing/history/ingest.ts b/src/routing/history/ingest.ts index 16a98d726b9..429fe4dc974 100644 --- a/src/routing/history/ingest.ts +++ b/src/routing/history/ingest.ts @@ -25,7 +25,7 @@ export const REQUEST_HISTORY_MAX_RECORD_BYTES = 1024 * 1024; // One small observation per live database handle, never retained ledger bytes. An // unterminated tail cannot advance indexedOffset, but need not be reread until its -// exact file revision changes. The indexer's schema/identity/quick_check gates still run. +// exact file revision changes. Schema/identity gates and lifecycle integrity checks still apply. const tailObservations = new WeakMap; + +export interface RequestHistoryIntegrityOptions { + createJob?: () => IntegrityJob; + now?: () => number; + intervalMs?: number; + failureCooldownMs?: number; + checkTimeoutMs?: number; + scheduleTimeout?: (callback: () => void, ms: number) => () => void; +} + +export type RequestHistoryIntegrityState = + | { state: "ready" } + | { state: "corrupt" } + | { state: "unavailable"; error: unknown } + // Never rejects. Inspect again for the verdict or operational failure. + | { state: "pending"; promise: Promise }; + +interface Generation { + handle: object; + path: string; + verified: boolean; + corrupt: boolean; + nextCheckAt: number; + failure: { error: unknown } | null; +} + +interface ActiveCheck { + generation: Generation; + cancelled: boolean; + cancel: () => void; + promise: Promise; + barrier: Promise; +} + +type CheckOutcome = + | { kind: "checked"; healthy: boolean } + | { kind: "failed"; error: unknown } + | { kind: "timeout"; error: Error } + | { kind: "cancelled" }; + +export class RequestHistoryIntegrityMonitor { + private readonly createJob: () => IntegrityJob; + private readonly now: () => number; + private readonly intervalMs: number; + private readonly failureCooldownMs: number; + private readonly checkTimeoutMs: number; + private readonly scheduleTimeout: (callback: () => void, ms: number) => () => void; + private current: Generation | null = null; + private active: ActiveCheck | null = null; + private readonly draining = new Set(); + + constructor(options: RequestHistoryIntegrityOptions = {}) { + this.createJob = options.createJob ?? (() => new RebuildWorkerJob()); + this.now = options.now ?? Date.now; + this.intervalMs = options.intervalMs ?? REQUEST_HISTORY_INTEGRITY_INTERVAL_MS; + this.failureCooldownMs = options.failureCooldownMs ?? REQUEST_HISTORY_INTEGRITY_FAILURE_COOLDOWN_MS; + this.checkTimeoutMs = options.checkTimeoutMs ?? REQUEST_HISTORY_INTEGRITY_CHECK_TIMEOUT_MS; + this.scheduleTimeout = options.scheduleTimeout ?? ((callback, ms) => { + const timer = setTimeout(callback, ms); + return () => clearTimeout(timer); + }); + } + + inspect(handle: object, path: string): RequestHistoryIntegrityState { + if (this.current?.handle !== handle || this.current.path !== path) { + this.quiesce(); + this.current = { handle, path, verified: false, corrupt: false, nextCheckAt: -Infinity, failure: null }; + } + const generation = this.current; + if (generation.corrupt) return { state: "corrupt" }; + const old = [...this.draining].filter(check => check.generation !== generation); + if (old.length) return { state: "pending", promise: Promise.all(old.map(check => check.barrier)).then(() => {}) }; + if (this.active) { + if (this.active.generation === generation && !this.active.cancelled && generation.verified) { + return { state: "ready" }; + } + return { state: "pending", promise: this.active.promise }; + } + if (this.now() >= generation.nextCheckAt) { + const check = this.start(generation); + if (check && !generation.verified) return { state: "pending", promise: check.promise }; + } + if (generation.verified) return { state: "ready" }; + return { state: "unavailable", error: generation.failure?.error ?? new Error("history integrity check unavailable") }; + } + + /** A statement detected corruption; an in-flight healthy verdict cannot erase it. */ + markCorrupt(handle: object): void { + if (this.current?.handle === handle) this.current.corrupt = true; + } + + /** + * Accept a newly reconstructed projection after successful inline ingest or + * validation of a private worker build. Existing persisted databases must + * use inspect instead. The caller drains scans before rebuilding/swapping. + */ + acceptRebuilt(handle: object, path: string): void { + if (this.draining.size) throw new Error("history integrity worker must exit before accepting a rebuilt index"); + this.current = { + handle, path, verified: true, corrupt: false, + nextCheckAt: this.now() + this.intervalMs, failure: null, + }; + } + + /** + * Invalidate and stop the current check. Null means the index can be replaced + * now; otherwise await the returned barrier before deleting or renaming it. + * The barrier survives reset/reopen and resolves only after actual worker + * exit, including Windows' OS-thread settling, not the bounded disposal wait. + */ + quiesce(): Promise | null { + this.current = null; + this.active?.cancel(); + return this.draining.size ? Promise.all([...this.draining].map(check => check.barrier)).then(() => {}) : null; + } + + reset(): void { + this.quiesce(); + } + + private start(generation: Generation): ActiveCheck | null { + let job: IntegrityJob; + try { + job = this.createJob(); + } catch (error) { + this.recordFailure(generation, error); + return null; + } + let cancel!: () => void; + const cancelled = new Promise(resolve => { cancel = () => resolve({ kind: "cancelled" }); }); + let release!: () => void; + const check: ActiveCheck = { + generation, + cancelled: false, + cancel: () => { check.cancelled = true; cancel(); }, + promise: new Promise(resolve => { release = resolve; }), + barrier: Promise.resolve(), + }; + this.active = check; + this.draining.add(check); + check.barrier = this.run(check, job, cancelled, release); + return check; + } + + private recordFailure(generation: Generation, error: unknown): void { + generation.failure = { error: error ?? new Error("history integrity check failed") }; + generation.nextCheckAt = this.now() + this.failureCooldownMs; + } + + private async run(check: ActiveCheck, job: IntegrityJob, cancelled: Promise, release: () => void): Promise { + let expire!: (outcome: CheckOutcome) => void; + const timeout = new Promise(resolve => { expire = resolve; }); + const clear = this.scheduleTimeout(() => expire({ kind: "timeout", error: new Error("request-history integrity check timed out") }), this.checkTimeoutMs); + const observed = Promise.resolve().then(async (): Promise => { + if (check.cancelled) return { kind: "cancelled" }; + try { + return { kind: "checked", healthy: await job.check(check.generation.path) }; + } catch (error) { + return { kind: "failed", error }; + } + }); + const outcome = await Promise.race([observed, cancelled, timeout]); + clear(); + if (outcome.kind === "timeout") { + if (this.current === check.generation && !check.cancelled && !check.generation.corrupt) { + this.recordFailure(check.generation, outcome.error); + } + if (this.active === check) this.active = null; + void job.dispose().catch(() => undefined); + release(); + } + await new Promise(resolve => { + // afterExit may return at its disposal timeout, before native I/O lets + // the worker exit. Only its callback releases the file replacement gate. + void job.afterExit(resolve).catch(() => undefined); + }); + const generation = check.generation; + if (this.current === generation && !check.cancelled && !generation.corrupt) { + if (outcome.kind === "checked") { + generation.verified = outcome.healthy; + generation.corrupt = !outcome.healthy; + generation.failure = null; + generation.nextCheckAt = this.now() + this.intervalMs; + } else if (outcome.kind === "failed") { + this.recordFailure(generation, outcome.error); + } + } + if (this.active === check) this.active = null; + this.draining.delete(check); + release(); + } +} diff --git a/src/routing/history/rebuild-job.ts b/src/routing/history/rebuild-job.ts index 18467701835..e98e2050aec 100644 --- a/src/routing/history/rebuild-job.ts +++ b/src/routing/history/rebuild-job.ts @@ -13,6 +13,7 @@ import { RequestHistoryIndexStaleError } from "./ingest"; import type { RebuildWorkerReply, RebuildWorkerRequest } from "./rebuild-protocol"; const CLOSE_TIMEOUT_MS = 5_000; +type WorkerSuccess = Exclude; function delay(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); @@ -34,7 +35,7 @@ function errorFromReply(reply: Extract): export class RebuildWorkerJob { private readonly worker: Worker; - private pending: { resolve: (rows: number) => void; reject: (error: unknown) => void } | null = null; + private pending: { resolve: (reply: WorkerSuccess) => void; reject: (error: unknown) => void } | null = null; private exited = false; private exitedCleanly = false; private disposal: Promise | null = null; @@ -49,8 +50,8 @@ export class RebuildWorkerJob { this.pending = null; if (!pending) return; const reply = event.data; - if (reply.type === "built") pending.resolve(reply.rows); - else pending.reject(errorFromReply(reply)); + if (reply.type === "error") pending.reject(errorFromReply(reply)); + else pending.resolve(reply); }); this.worker.addEventListener("error", (event: Event) => { this.fail(new Error((event as ErrorEvent).message || "request-history rebuild worker failed")); @@ -68,16 +69,29 @@ export class RebuildWorkerJob { pending?.reject(error); } - /** Build the complete private file; resolves with the ingested row count. */ - build(indexPath: string, buildPath: string, reason: string, revision: UsageLogRevision): Promise { + private send(request: RebuildWorkerRequest): Promise { if (this.exited || this.disposal) return Promise.reject(new Error("request-history rebuild worker is not running")); if (this.pending) return Promise.reject(new Error("request-history rebuild worker is busy")); return new Promise((resolve, reject) => { this.pending = { resolve, reject }; - this.worker.postMessage({ type: "build", indexPath, buildPath, reason, revision } satisfies RebuildWorkerRequest); + this.worker.postMessage(request); }); } + /** Build the complete private file; resolves with the ingested row count. */ + async build(indexPath: string, buildPath: string, reason: string, revision: UsageLogRevision): Promise { + const reply = await this.send({ type: "build", indexPath, buildPath, reason, revision }); + if (reply.type !== "built") throw new Error("unexpected request-history build reply"); + return reply.rows; + } + + /** Scan a read-only connection off-loop; operational errors are not corruption. */ + async check(indexPath: string): Promise { + const reply = await this.send({ type: "check", indexPath }); + if (reply.type !== "checked") throw new Error("unexpected request-history integrity reply"); + return reply.healthy; + } + /** Stop the thread; waits a bounded time for it to exit. */ dispose(): Promise { this.disposal ??= this.shutDown(); diff --git a/src/routing/history/rebuild-protocol.ts b/src/routing/history/rebuild-protocol.ts index eb5999d29a5..d0dde97af6e 100644 --- a/src/routing/history/rebuild-protocol.ts +++ b/src/routing/history/rebuild-protocol.ts @@ -8,8 +8,9 @@ export type RebuildWorkerRequest = { buildPath: string; reason: string; revision: UsageLogRevision; -}; +} | { type: "check"; indexPath: string }; export type RebuildWorkerReply = | { type: "built"; rows: number } + | { type: "checked"; healthy: boolean } | { type: "error"; name: string; message: string }; diff --git a/src/routing/history/rebuild-worker.ts b/src/routing/history/rebuild-worker.ts index 6ee44b081d8..a636ed4d93e 100644 --- a/src/routing/history/rebuild-worker.ts +++ b/src/routing/history/rebuild-worker.ts @@ -8,12 +8,30 @@ * closes it. The serving thread then swaps the finished file in. */ +import { Database } from "bun:sqlite"; import { buildIndexFile, removeAbandonedBuilds } from "./build-file"; +import { isHistoryIndexCorruption } from "./sqlite-errors"; import type { RebuildWorkerReply, RebuildWorkerRequest } from "./rebuild-protocol"; declare const self: Worker; function handle(request: RebuildWorkerRequest): RebuildWorkerReply { + if (request.type === "check") { + let handle: Database | undefined; + try { + handle = new Database(request.indexPath, { readonly: true }); + handle.exec("PRAGMA busy_timeout = 5000"); + const row = handle.query("PRAGMA quick_check").get() as { quick_check?: string } | undefined; + return { type: "checked", healthy: row?.quick_check === "ok" }; + } catch (error) { + if (isHistoryIndexCorruption(error)) return { type: "checked", healthy: false }; + throw error; + } finally { + // Release the live index before posting a verdict: the serving thread + // may immediately replace it, including on Windows. + handle?.close(); + } + } removeAbandonedBuilds(request.indexPath); const rows = buildIndexFile(request.buildPath, request.reason, request.revision); return { type: "built", rows }; diff --git a/src/routing/history/sqlite-errors.ts b/src/routing/history/sqlite-errors.ts new file mode 100644 index 00000000000..9e8101954d8 --- /dev/null +++ b/src/routing/history/sqlite-errors.ts @@ -0,0 +1,9 @@ +/** Only structural SQLite damage justifies discarding the derived index. */ +export function isHistoryIndexCorruption(error: unknown): boolean { + if (!error || typeof error !== "object") return false; + const { code, errno } = error as { code?: unknown; errno?: unknown }; + // Bun exposes extended result codes too; the low byte is the primary code. + if (typeof errno === "number" && ((errno & 0xff) === 11 || (errno & 0xff) === 26)) return true; + return typeof code === "string" + && /^(SQLITE_CORRUPT(?:_[A-Z]+)?|SQLITE_NOTADB)$/.test(code); +} diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 4d124d4ac7e..11059690ed2 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -1089,6 +1089,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = { annotateEmptyToolOutputs: "editor", supportsServiceTier: "editor", modelSupportsServiceTier: "editor", + promoteAdditionalTools: "editor", preserveResponsesReasoningContent: "editor", preserveResponsesInputItemIds: "editor", preserveResponsesMessageMetadata: "editor", diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 2c26e6e6e32..a2d6b25c676 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -970,6 +970,83 @@ async function handleClaudeMessagesWithBudget( ...(settledRoute?.provider.authMode === "oauth" && settledInstance ? { oauthFailoverQuorum: anthropicRoutingFor(settledInstance).hasAnthropicFailoverQuorum() } : {}), }; + if (settledRoute && isRec(anthropicBody) + && nativeMessagesDeclineReason(settledRoute, anthropicBody, config, nativeSelector) === "bridge-only-policy" + && nativeMessagesDeclineReason(settledRoute, anthropicBody, config, { ...nativeSelector, webSearchBackendAvailable: false }) === undefined) { + const { parseRequest } = await import("../responses/parser"); + const { planWebSearch, shouldResolveOpenAiWebSearchSidecar } = await import("../web-search"); + const replayConfig = buildClaudeReplayConfig(config); + const parsed = parseRequest(internalBody); + let openAiSidecar: import("../providers/openai-sidecar").ResolvedOpenAiForwardSidecar | undefined; + if (shouldResolveOpenAiWebSearchSidecar(replayConfig, parsed, false)) { + const { listOpenAiForwardSidecarCandidates, captureExplicitOpenAiCallerAuth, resolveFirstUsableOpenAiSidecar } = await import("../providers/openai-sidecar"); + const candidates = listOpenAiForwardSidecarCandidates(replayConfig); + const headers = new Headers(); + for (const name of FORWARD_HEADERS) { + if (name === "authorization" || name === "chatgpt-account-id") continue; + const value = req.headers.get(name); + if (value) headers.set(name, value); + } + const { codexAccountSelectionForTurn, tryAdmitTurn } = await import("./lifecycle"); + // The probe owns its claim only until selection completes, not for the client turn. + const probeLease = logIds?.turnAdmissionLease && candidates.length ? tryAdmitTurn() : undefined; + const errors = await import("../codex/auth-context"); + try { + req.signal.throwIfAborted(); + if (logIds?.turnAdmissionLease && candidates.length && !probeLease) { + // No independent admission means availability is unknown; preserve the bridge. + nativeSelector.webSearchBackendAvailable = true; + } else { + if (candidates.some(candidate => candidate.accountMode === "direct") && probeLease) { + if (!tryClaimNativeMainProfileForTurn(probeLease)) { + nativeSelector.webSearchBackendAvailable = true; + } else { + const { getMainAccountToken } = await import("../codex/main-account"); + const token = getMainAccountToken(); + if (token) { + const auth = captureExplicitOpenAiCallerAuth(new Headers({ authorization: `Bearer ${token.accessToken}`, "chatgpt-account-id": token.chatgptAccountId }), replayConfig); + if (auth) { headers.set("authorization", auth.authorization); headers.set("chatgpt-account-id", auth.chatgptAccountId); } + } + } + } + openAiSidecar = await resolveFirstUsableOpenAiSidecar(candidates, headers, replayConfig, { + admission: logIds?.admission, codexAuthPolicy: config, signal: req.signal, + beginCodexAccountSelection: codexAccountSelectionForTurn(probeLease ?? undefined), + }); + req.signal.throwIfAborted(); + // The bridge can inject main auth into pool selection. Only a negative pool probe + // needs this extra read; an unavailable claim or present token leaves it uncertain. + if (!openAiSidecar && probeLease && candidates.some(candidate => candidate.accountMode === "pool")) { + if (!tryClaimNativeMainProfileForTurn(probeLease)) nativeSelector.webSearchBackendAvailable = true; + else { + const { getMainAccountToken } = await import("../codex/main-account"); + if (getMainAccountToken()) nativeSelector.webSearchBackendAvailable = true; + } + } + } + } catch (err) { + // Refresh wraps cancellation in CodexAuthContextError; a cancelled request must not continue. + if (req.signal.aborted) { + openAiSidecar?.releaseProbeLease?.(); + throw req.signal.reason ?? err; + } + if (!(err instanceof errors.CodexPoolAuthenticationError) && !(err instanceof errors.CodexAuthContextError) + && !(err instanceof errors.CodexAccountCooldownError) && !(err instanceof errors.CodexThreadAffinityExpiredError) + && !(err instanceof errors.CodexMainProfileDrainingError)) { + openAiSidecar?.releaseProbeLease?.(); + throw err; + } + if (probeLease && candidates.some(candidate => candidate.accountMode === "pool")) nativeSelector.webSearchBackendAvailable = true; + } finally { probeLease?.release(); } + } + try { + nativeSelector.webSearchBackendAvailable ??= !!planWebSearch(replayConfig, parsed, false, settledRoute.provider, settledRoute.modelId, openAiSidecar, { + admission: logIds?.admission, codexAuthPolicy: config, providerName: settledRoute.providerName, + }); + } finally { + openAiSidecar?.releaseProbeLease?.(); + } + } const nativeDecline = settledRoute && isRec(anthropicBody) ? nativeMessagesDeclineReason(settledRoute, anthropicBody, config, nativeSelector) : "rollout-disabled"; diff --git a/src/server/configured-credentials.ts b/src/server/configured-credentials.ts index f1143938446..2b44fe778aa 100644 --- a/src/server/configured-credentials.ts +++ b/src/server/configured-credentials.ts @@ -163,6 +163,11 @@ function build(configRead: StoreRead, authRead: StoreRead, signature: string, no || (config.value.providers !== undefined && !isRecord(config.value.providers)))) complete = false; if (authSig !== "absent" && auth.ok && !isRecord(auth.value)) complete = false; if (isRecord(config.value) && isRecord(config.value.providers)) walkProvider(config.value.providers, 0); + if (isRecord(config.value) && isRecord(config.value.codexRoleCapability) + && isRecord(config.value.codexRoleCapability.artificialAnalysis)) { + const key = config.value.codexRoleCapability.artificialAnalysis.apiKey; + addReference(typeof key === "string" ? key.trim() : key); + } if (isRecord(auth.value)) walkAuth(auth.value, 0); const names = [...envNames].sort(); diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index db957a73e72..85a80da579b 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -1,4 +1,6 @@ import { prepareClaudeCodePatch } from "./claude-code-settings-patch"; +import { beginCodexCliSelectionParentWriteAsync, withCodexCliSelectionParentWriteAsync, + CodexCliSelectionMutationBusyError, type CodexCliSelectionAsyncParentWrite } from "../../codex/cli-update-selection-gate"; import { readConfigForAdmittedMutation } from "../../config/persisted-mutation"; import { mirrorDesiredEnabledOntoSnapshot, persistDesktopModeField, persistDesktopProfileField } from "./claude-desktop-config-writers"; import { getConfigDir as configRoot, withConfigDirHardeningSettled as settleConfigRoot } from "../../config/paths"; @@ -205,6 +207,7 @@ export async function injectionModelView(config: OcxConfig, routed: readonly Cat export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise { try { return await handleAgentSettingsRoutesImpl(ctx); } catch (error) { + if (error instanceof CodexCliSelectionMutationBusyError) return jsonResponse({ error: error.message, retryable: true }, 503); if (!(error instanceof WindowsSecretAclSettleTimeoutError)) throw error; return jsonResponse({ error: "Could not save settings", code: "write_failed", diagnostic: { stage: "ca", cause: "ETIMEDOUT" } }, 500); } @@ -402,10 +405,8 @@ async function handleAgentSettingsRoutesImpl(ctx: ManagementContext): Promise { + if (wantsFlag || wantsMode || wantsThreads || wantsKeepNative || (wantsModeHintText && body.multiAgentModeHintText !== null)) { + selectionWrite = await beginCodexCliSelectionParentWriteAsync({ signal: req.signal }); + if (!selectionWrite.entered) return jsonResponse({ error: "codex_cli_selection_busy", retryable: true }, 503); + } + const commit = () => { const currentPlan = readModePlan(); if (currentPlan instanceof Response) return currentPlan; const { effectiveMode } = currentPlan; @@ -497,9 +503,11 @@ async function handleAgentSettingsRoutesImpl(ctx: ManagementContext): Promise selectionWrite ? selectionWrite.commit(commit) : commit()); if (writeRefusal) return writeRefusal; } finally { + selectionWrite?.exit(); if (heldWriteLock) releaseConfigWriteLock(heldWriteLock); } // Derived from fresh post-write readers (readConfigText is uncached): upstream @@ -558,9 +566,9 @@ async function handleAgentSettingsRoutesImpl(ctx: ManagementContext): Promise runCodexFeaturesCommand(action, DEFAULT_MODE_REQUEST_USER_INPUT_FEATURE_KEY, env, validate, invocation); } catch (error) { return jsonResponse({ error: `default_mode_request_user_input toggle failed: ${error instanceof Error ? error.message : String(error)}` }, 502); diff --git a/src/server/management/codex-agent-role-routes.ts b/src/server/management/codex-agent-role-routes.ts index d9d55614dce..d8e80d911da 100644 --- a/src/server/management/codex-agent-role-routes.ts +++ b/src/server/management/codex-agent-role-routes.ts @@ -82,6 +82,7 @@ export async function handleCodexAgentRoleRoutes(ctx: ManagementContext): Promis ...(typeof body?.model === "string" ? { sizingModel: body.model } : {}), fetchAllModels: deps.fetchAllModels ?? fetchAllModels, ...(deps.completeCodexRoleSizing ? { completeRoleSizing: deps.completeCodexRoleSizing } : {}), + ...(deps.loadCodexRoleCapability ? { loadCapability: deps.loadCodexRoleCapability } : {}), }), 200, req, config); } catch (error) { if (error instanceof NoSizingModelError) { diff --git a/src/server/management/codex-role-auto-assign.ts b/src/server/management/codex-role-auto-assign.ts index f9f2381ec4f..c951fabfc2b 100644 --- a/src/server/management/codex-role-auto-assign.ts +++ b/src/server/management/codex-role-auto-assign.ts @@ -9,6 +9,8 @@ import { join } from "node:path"; import type { OcxConfig } from "../../types"; import type { CatalogModel } from "../../codex/catalog"; import type { RoleModelCandidate } from "../../codex/role-auto-assign"; +import type { LoadCapability } from "../../codex/role-capability"; +import type { SizingDomain } from "../../codex/role-sizing"; export interface RoleSizingCall { readonly model: string; @@ -118,11 +120,13 @@ export async function proposeCodexRoleModels(options: { sizingModel?: string; fetchAllModels: (config: OcxConfig) => Promise; completeRoleSizing?: CompleteRoleSizing; + loadCapability?: LoadCapability; }) { - const [roles, sizing, assign, { readConfiguredDefaultModel }] = await Promise.all([ + const [roles, sizing, assign, capability, { readConfiguredDefaultModel }] = await Promise.all([ import("../../codex/agent-role-models"), import("../../codex/role-sizing"), import("../../codex/role-auto-assign"), + import("../../codex/role-capability"), import("../../codex/catalog/parsing"), ]); const sizingModel = resolveSizingModel(options.sizingModel, readConfiguredDefaultModel); @@ -159,15 +163,55 @@ export async function proposeCodexRoleModels(options: { } } - const classified = assign.classifyRoleModelCandidates( - await roleModelCandidates(options.config, await options.fetchAllModels(options.config)), - options.config.codexRoleTiers, - ); + const candidates = await roleModelCandidates(options.config, await options.fetchAllModels(options.config)); + const classified = assign.classifyRoleModelCandidates(candidates, options.config.codexRoleTiers); + // Measured capability is a better tier signal than price, and its absence is not an error: a + // failed read leaves every role on price rank and says so. + const domains = new Set([...outcomes.values()].flatMap(outcome => "sizing" in outcome ? [outcome.sizing.domain] : [])); + let table: import("../../codex/role-capability").CapabilityTable | null = null; + let capabilityError: string | null = null; + if (domains.size > 0) { + try { + table = await (options.loadCapability ?? capability.loadCapabilityTable)(options.config); + } catch { + capabilityError = "the measured capability source could not be read"; + } + } + const classifiedByDomain = new Map>(); + for (const domain of domains) { + const scores = table?.domains[domain]; + if (!scores) continue; + const measured = new Map(candidates.flatMap(candidate => { + const score = scores.get(capability.candidateModelKey(candidate.model)); + return score === undefined ? [] : [[candidate.model, score] as const]; + })); + classifiedByDomain.set(domain, assign.classifyRoleModelCandidates(candidates, options.config.codexRoleTiers, measured)); + } return { sizingModel, sizingError, - proposals: assign.buildRoleProposals(roleRows, outcomes, classified), + proposals: assign.buildRoleProposals(roleRows, outcomes, classified, classifiedByDomain), candidates: classified.map(({ model, tier, tierSource, unitPrice }) => ({ model, tier, tierSource, unitPrice })), + capability: capabilityReport(table?.source ?? null, table, capabilityError, classifiedByDomain), + }; +} + +/** The attribution and per-domain tiers; raw scores only from a source whose terms allow it. */ +function capabilityReport( + source: import("../../codex/role-capability").CapabilitySource | null, + table: import("../../codex/role-capability").CapabilityTable | null, + error: string | null, + classifiedByDomain: ReadonlyMap>, +) { + return { + name: source?.name ?? null, + url: source?.url ?? null, + license: source?.license ?? null, + publishedAt: table?.publishedAt ?? null, + error: error ?? table?.fallbackReason ?? null, + domains: Object.fromEntries([...classifiedByDomain].map(([domain, list]) => [domain, + list.filter(candidate => candidate.tierSource === "measured") + .map(({ model, tier, score }) => (source?.redistributable ? { model, tier, score } : { model, tier }))])), }; } diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 144ff32fb38..3283a2409ce 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -972,11 +972,22 @@ async function handleConfigRoutesImpl(ctx: ManagementContext): Promise checked, spawnWorkerFn: (jobId, runChannel, runRestart) => spawnGuiUpdateWorker(jobId, runChannel, runRestart, { resolveSystemdRun: () => systemdRun }), @@ -990,8 +1001,8 @@ async function handleConfigRoutesImpl(ctx: ManagementContext): Promise Promise; + /** Test seam for the dashboard restart supervisor check (src/update/worker-launch.ts). */ + dashboardRestartRefusal?: typeof import("../../update/worker-launch").dashboardRestartRefusal; remoteWorkspaceHub?: RemoteWorkspaceHubApi; remoteWorkspaceSessions?: RemoteWorkspaceSessionsApi; /** The listener retains and awaits teardown only after this optional subsystem activates. */ @@ -91,6 +93,7 @@ export interface ManagementApiDeps { fetchAllModels?: (config: OcxConfig) => Promise; /** Codex role auto-assign's one sizing model call; route tests answer it without a provider. */ completeCodexRoleSizing?: import("./codex-role-auto-assign").CompleteRoleSizing; + loadCodexRoleCapability?: import("../../codex/role-capability").LoadCapability; /** * Writer seam for the Grok toggle: lets a test place the file in any state * between the pre-write recheck and the write itself (the r8 post-inspection diff --git a/src/server/management/native-integration-routes.ts b/src/server/management/native-integration-routes.ts index 8b75adec412..be01108fbf8 100644 --- a/src/server/management/native-integration-routes.ts +++ b/src/server/management/native-integration-routes.ts @@ -1,4 +1,5 @@ import { readConfigForAdmittedMutation } from "../../config/persisted-mutation"; +import { ConfigWritePublishedError } from "../../config/persist-unlocked"; import { getConfigDir as configRoot, withConfigDirHardeningSettled as settleConfigRoot } from "../../config/paths"; import { WindowsSecretAclSettleTimeoutError } from "../../lib/windows-secret-acl"; import { mirrorDesiredEnabledOntoSnapshot } from "./claude-desktop-config-writers"; @@ -949,6 +950,9 @@ export async function handleNativeIntegrationRoutes(ctx: ManagementContext): Pro const enabled = body.enabled; if (claudeCodeEnabled(config) === enabled) { + // Re-run the same reconcile on a repeated OFF: definitions left behind by an older + // version that never pruned on disable are removed the next time the user says off. + if (!enabled) await ctx.syncClaudeAgentDefsBestEffort?.(); return jsonResponse({ ok: true, clientId: "claude", changed: false, state: enabled ? "current" : "absent", @@ -963,6 +967,10 @@ export async function handleNativeIntegrationRoutes(ctx: ManagementContext): Pro * sentinel the next startServer would silently convert a user's Auto auth mode into * a sticky manual subscription — a failure that surfaces nowhere near this route. */ + // Kept so a failed save leaves the shared in-memory config matching disk: otherwise a + // retry on the same object would read the unsaved value, take the repeated-OFF branch + // and prune agents while the persisted integration is still on. + const previousBlock = config.claudeCode === undefined ? undefined : structuredClone(config.claudeCode); commitClaudeCodeBlock(config, { ...(config.claudeCode ?? {}), enabled }); /* @@ -973,6 +981,11 @@ export async function handleNativeIntegrationRoutes(ctx: ManagementContext): Pro try { persist(config); } catch (error) { + // A published write already put the new value on disk; only an unpublished one rolls back. + if (!(error instanceof ConfigWritePublishedError)) { + if (previousBlock === undefined) delete config.claudeCode; + else config.claudeCode = previousBlock; + } if (isConfigLockError(error)) { return isLockContention(error) ? refusal(409, "claude", "config_busy", @@ -983,6 +996,15 @@ export async function handleNativeIntegrationRoutes(ctx: ManagementContext): Pro throw error; } + /* + * The generated ~/.claude/agents/ocx-*.md definitions route through this proxy; a + * native Claude Code session that still loads them after OFF follows their + * placeholder model (issue #7968). Prune them now instead of waiting for a later + * launch or startup sync; the disabled reconcile removes only marker-owned regular + * files and needs no model discovery. ON keeps its existing launch/startup sync. + */ + if (!enabled) await ctx.syncClaudeAgentDefsBestEffort?.(); + let interceptReason = enabled && interceptStartRefusal(ctx) ? "intercept_start_forbidden" : null; if (enabled && !interceptReason) { const outcome = await ensureManagementClaudeIntercept(ctx); diff --git a/src/server/management/provider-overwrite-carry.ts b/src/server/management/provider-overwrite-carry.ts index 08f3ceff314..6b1a9b0d550 100644 --- a/src/server/management/provider-overwrite-carry.ts +++ b/src/server/management/provider-overwrite-carry.ts @@ -51,6 +51,7 @@ const RETRY_POLICY_VALIDATORS = { export const PROVIDER_COMPAT_CARRY_FIELDS = [ "preserveReasoningContentModels", "preserveResponsesReasoningContent", + "promoteAdditionalTools", "requiresReasoningPlaceholderModels", "foldDeveloperRoleToSystem", "singleLeadingSystemMessage", @@ -167,6 +168,8 @@ export function providerCompatFieldConfigError(provider: Record if (hideRawReasoning !== undefined && typeof hideRawReasoning !== "boolean") return "hideRawReasoning must be a boolean"; const preserveResponses = provider.preserveResponsesReasoningContent; if (preserveResponses !== undefined && typeof preserveResponses !== "boolean") return "preserveResponsesReasoningContent must be a boolean"; + const promoteAdditionalTools = provider.promoteAdditionalTools; + if (promoteAdditionalTools !== undefined && typeof promoteAdditionalTools !== "boolean") return "promoteAdditionalTools must be a boolean"; const wire = provider.reasoningWireFormat; if (wire !== undefined && !PROVIDER_REASONING_WIRE_FORMATS.includes(wire as never)) { return `reasoningWireFormat must be one of: ${PROVIDER_REASONING_WIRE_FORMATS.join(", ")}`; @@ -225,6 +228,13 @@ export function applyProviderCompatPatchFields( else return { error: "preserveResponsesReasoningContent must be a boolean" }; touched = true; } + if (Object.hasOwn(rawBody, "promoteAdditionalTools")) { + const value = rawBody.promoteAdditionalTools; + if (value === null) delete next.promoteAdditionalTools; + else if (typeof value === "boolean") next.promoteAdditionalTools = value; + else return { error: "promoteAdditionalTools must be a boolean" }; + touched = true; + } if (Object.hasOwn(rawBody, "inferContextWindow")) { const value = rawBody.inferContextWindow; if (value === null) delete next.inferContextWindow; diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 6e4826a959d..75a833095ad 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -63,6 +63,7 @@ import { import { shadowInterceptProviderDependency } from "./shadow-call-validation"; import { deriveProviderPresets, providerConfigSeed } from "../../providers/derive"; import { initializeProviderModelSelection } from "../../providers/initial-model-selection"; +import { invalidModelDiscoveryHint } from "../../providers/model-discovery-diagnostic"; import { effectiveGoogleMode, providerCodexAccountMode, providerMatchesRegistryTransport } from "../../providers/registry"; import { extractModelEnvelopeRows, @@ -907,6 +908,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise 0, allowPrivateNetwork: p.allowPrivateNetwork === true, preserveResponsesReasoningContent: p.preserveResponsesReasoningContent, + promoteAdditionalTools: p.promoteAdditionalTools, liveModels: p.liveModels !== false, requestPacing: p.requestPacing, models: p.models ?? [], @@ -1802,7 +1804,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { + const { req, config } = ctx; + const body = await readManagementJsonBodyOr(req, INVALID_BODY); + const parsed = body === null ? null : workflowBudgetSchema.safeParse(body); + if (parsed !== null && !parsed.success) { + return jsonResponse( + { error: { code: "invalid_workflow_budget", message: "body must be null or an object that " + WORKFLOW_BUDGET_SHAPE_HINT } }, + 400, + req, + config, + ); + } + // Resolve the saver before changing the live object: no await may interleave mutation/save. + const persist = ctx.deps.saveConfigPreservingClaudeCode + ?? (await import("../../config")).saveConfigPreservingClaudeCode; + const restore = captureConfigTopLevelRollback(config, ["workflowBudget"]); + try { + if (parsed === null) deleteConfigTopLevelKey(config, "workflowBudget"); + else config.workflowBudget = { ...config.workflowBudget, ...parsed.data }; + persist(config); + } catch (error) { + // Published bytes remain authoritative even if later bookkeeping failed. + if (!(error instanceof ConfigWritePublishedError)) restore(); + throw error; + } + return jsonResponse(workflowBudgetSettings(config), 200, req, config); +} /** * A root id is an opaque caller-thread token, not a path. Without a length cap a client @@ -60,6 +121,12 @@ function parseLimitParam(rawLimit: string | null): { ok: true; limit: number } | export async function handleWorkflowBudgetRoutes(ctx: ManagementContext): Promise { const { url, req, config } = ctx; + if (url.pathname === "/api/workflow-budget/settings" && req.method === "GET") { + return jsonResponse(workflowBudgetSettings(config), 200, req, config); + } + if (url.pathname === "/api/workflow-budget/settings" && req.method === "PUT") return putWorkflowBudgetSettings(ctx); + const policy = resolveWorkflowBudgetPolicy(config); + if (url.pathname === "/api/workflow-budget") { if (req.method !== "GET") return null; @@ -78,7 +145,7 @@ export async function handleWorkflowBudgetRoutes(ctx: ManagementContext): Promis if (rawRoot !== null) { const rootId = parseRootId(rawRoot); if (rootId === null) return invalidRootResponse(req, config); - const snapshot = workflowBudgetSnapshot(rootId); + const snapshot = workflowBudgetSnapshot(rootId, policy); // An unknown id is a 200 with `root: null`, not a 404: the operator asked what this // process currently holds for that token, and "nothing" is a legitimate answer. POST // /clear is the opposite — claiming to forgive a ceiling that was never tracked would @@ -98,7 +165,7 @@ export async function handleWorkflowBudgetRoutes(ctx: ManagementContext): Promis return jsonResponse( { - roots: listTrackedWorkflowRoots(limit), + roots: listTrackedWorkflowRoots(limit, policy), events: listWorkflowBudgetEvents(limit), }, 200, @@ -117,7 +184,7 @@ export async function handleWorkflowBudgetRoutes(ctx: ManagementContext): Promis const rootId = parseRootId(rawRoot); if (rootId === null) return invalidRootResponse(req, config); - const before = clearWorkflowBudgetForRoot(rootId); + const before = clearWorkflowBudgetForRoot(rootId, policy); if (!before) { return jsonResponse( { error: { code: "unknown_root", message: "root is not currently tracked" } }, diff --git a/src/server/messages-native-eligibility.ts b/src/server/messages-native-eligibility.ts index bf878853d6b..79fc3fb56fe 100644 --- a/src/server/messages-native-eligibility.ts +++ b/src/server/messages-native-eligibility.ts @@ -2,8 +2,8 @@ * Whether a settled Messages route may take the managed native Messages lane (PF-08). * * PURE: reads the route, the body's structure and config, and nothing else. The Messages ingress, - * `count_tokens` and the protocol planner (`src/protocols/plan-snapshot.ts`) all ask here, so a - * preview, a count and the real send cannot disagree about the lane. + * `count_tokens` and the protocol planner (`src/protocols/plan-snapshot.ts`) all ask here; + * previews and counts remain conservative when runtime availability is unknown. */ import { anthropicBodyElidesBlockedSkill } from "../claude/inbound"; import { isClaudeWebSearchToolName } from "../claude/outbound"; @@ -48,6 +48,8 @@ export interface NativeMessagesSelector { * and judges OAuth from config alone. */ oauthFailoverQuorum?: boolean; + /** Runtime sidecar plan availability; absent for config-only callers. */ + webSearchBackendAvailable?: boolean; } type Rec = Record; @@ -59,8 +61,7 @@ function isRec(value: unknown): value is Rec { * Whether the bridge could hand this request to the web-search sidecar: a `web_search*` server * tool the tool choice does not exclude, with the sidecar not disabled in the Claude replay config * (`buildClaudeReplayConfig` merges `claudeCode.webSearchSidecar` over the global block). Whether - * a backend credential then exists is decided at dispatch and is not read here, so this errs - * toward the bridge, which is today's behavior for every such request. + * a backend credential exists is supplied separately by the sending ingress. */ function webSearchSidecarMayEngage(body: Readonly, config: OcxConfig): boolean { const sidecar = { ...config.webSearchSidecar, ...config.claudeCode?.webSearchSidecar }; @@ -89,7 +90,7 @@ function bridgeOnlyPolicyApplies( ): boolean { if (resolvePinnedEffort(route, selector.routeSelector, config) !== undefined) return true; if (anthropicBodyElidesBlockedSkill(body, selector.claudeCode ?? config.claudeCode)) return true; - return webSearchSidecarMayEngage(body, config); + return selector.webSearchBackendAvailable !== false && webSearchSidecarMayEngage(body, config); } /** diff --git a/src/server/responses-snapshot-repair.ts b/src/server/responses-snapshot-repair.ts index 389b8374900..1f3c2da3391 100644 --- a/src/server/responses-snapshot-repair.ts +++ b/src/server/responses-snapshot-repair.ts @@ -169,6 +169,9 @@ type OpenItem = { /** The gateway sent content_part.done for this item. */ partDone: boolean; text: string; + /** Exact UTF-8 size and boundary unit, without rescanning the accumulated text. */ + textBytes: number; + lastCodeUnit: number; item: Record; }; @@ -219,7 +222,7 @@ export function createResponsesSnapshotBlockRewrite( const closeOpenItem = (index: number): void => { const open = openItems.get(index); if (!open) return; - aggregateOpenTextBytes -= Buffer.byteLength(open.text, "utf8"); + aggregateOpenTextBytes -= open.textBytes; openItems.delete(index); }; @@ -313,6 +316,8 @@ export function createResponsesSnapshotBlockRewrite( textDone: false, partDone: false, text: "", + textBytes: 0, + lastCodeUnit: -1, item: event.item, }); } @@ -407,12 +412,19 @@ export function createResponsesSnapshotBlockRewrite( return [changed ? jsonBlock(nextEvent) : block]; } if (open && (itemId === undefined || itemId === open.itemId)) { - const deltaBytes = Buffer.byteLength(event.delta, "utf8"); + let deltaBytes = Buffer.byteLength(event.delta, "utf8"); + const first = event.delta.charCodeAt(0); + // Joining two isolated surrogate halves replaces two 3-byte replacements with 4 bytes. + if (open.lastCodeUnit >= 0xd800 && open.lastCodeUnit <= 0xdbff && first >= 0xdc00 && first <= 0xdfff) { + deltaBytes -= 2; + } + if (event.delta.length > 0) open.lastCodeUnit = event.delta.charCodeAt(event.delta.length - 1); open.text += event.delta; + open.textBytes += deltaBytes; aggregateOpenTextBytes += deltaBytes; // Unbounded text accumulation is a retention hole (#893 review): // overshoot per-item or aggregate caps and the stream goes fail-closed. - if (Buffer.byteLength(open.text, "utf8") > MAX_COMPLETED_OUTPUT_ITEM_SOURCE_BYTES + if (open.textBytes > MAX_COMPLETED_OUTPUT_ITEM_SOURCE_BYTES || aggregateOpenTextBytes > MAX_OPEN_ITEM_AGGREGATE_TEXT_BYTES) { taintAndRelease(); } @@ -437,9 +449,11 @@ export function createResponsesSnapshotBlockRewrite( if (typeof event.text === "string") { // Replacement must keep the aggregate honest and bounded, the // same as delta accumulation (#893 review). - const previousBytes = Buffer.byteLength(open.text, "utf8"); + const previousBytes = open.textBytes; const nextBytes = Buffer.byteLength(event.text, "utf8"); open.text = event.text; + open.textBytes = nextBytes; + open.lastCodeUnit = event.text.charCodeAt(event.text.length - 1); aggregateOpenTextBytes += nextBytes - previousBytes; if (nextBytes > MAX_COMPLETED_OUTPUT_ITEM_SOURCE_BYTES || aggregateOpenTextBytes > MAX_OPEN_ITEM_AGGREGATE_TEXT_BYTES) { diff --git a/src/server/responses-undeclared-tool-guard.ts b/src/server/responses-undeclared-tool-guard.ts index d64a802b2ab..6a2784c7c68 100644 --- a/src/server/responses-undeclared-tool-guard.ts +++ b/src/server/responses-undeclared-tool-guard.ts @@ -1,4 +1,5 @@ import { UNDECLARED_TOOL_CALL_ERROR_CODE } from "../lib/errors"; +import { hasItemProvenance, isHistoricalItem } from "../responses/item-provenance"; import { collectAmbiguousDottedAliases, dottedAliasIsUnambiguous, @@ -136,6 +137,9 @@ export function currentTurnWireToolCatalogBody( replayPrefixLength: number | undefined, ): unknown { if (!isPlainObject(body) || !Array.isArray(body.input)) return body; + if (body.input.some(hasItemProvenance)) { + return { ...body, input: body.input.filter(item => !isHistoricalItem(item)) }; + } if (typeof replayPrefixLength !== "number" || !Number.isFinite(replayPrefixLength)) return body; const start = Math.min(body.input.length, Math.max(0, Math.trunc(replayPrefixLength))); if (start === 0) return body; diff --git a/src/server/responses/combo-required-tool-contract.ts b/src/server/responses/combo-required-tool-contract.ts index 1e23e46873e..a5177b364d8 100644 --- a/src/server/responses/combo-required-tool-contract.ts +++ b/src/server/responses/combo-required-tool-contract.ts @@ -1,4 +1,6 @@ import { REQUIRED_TOOL_CONTRACT_CODE } from "../../lib/errors"; +import { mapToolChoice } from "../../responses/parser-tools"; +import { modelInList, type OcxProviderConfig } from "../../types"; import { hasResponseMediaType } from "../../lib/http-response-semantics"; import { applyReplayRefusalClientHeaders, isNonReplayableResponse, markResponseNonReplayable } from "../../lib/upstream-retry"; import { clientWireOf } from "../inference/client-wire"; @@ -10,6 +12,43 @@ export type RequiredToolContract = | { kind: "any"; hostedCallTypes: ReadonlySet } | { kind: "named"; name: string }; +const allowedHostedSelectors = new Set([ + "web_search", "web_search_preview", "file_search", "computer_use_preview", "computer_use", + "code_interpreter", "image_generation", "image_gen", "tool_search", +]); + +function knownToolSelector(value: unknown): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const entry = value as Record; + if (entry.type === "mcp") return typeof entry.server_label === "string" && !!entry.server_label.trim() + && (entry.name === undefined || entry.name === null || (typeof entry.name === "string" && !!entry.name.trim())); + if (entry.name !== undefined && (typeof entry.name !== "string" || !entry.name.trim())) return false; + if (entry.type === "function" || entry.type === "custom") return typeof entry.name === "string"; + return typeof entry.type === "string" && allowedHostedSelectors.has(entry.type); +} + +/** Only definite, well-formed mandatory selectors justify excluding a destination. */ +export function hasMandatoryToolChoice(rawBody: unknown): boolean { + if (!rawBody || typeof rawBody !== "object" || Array.isArray(rawBody)) return false; + const choice = (rawBody as Record).tool_choice; + if (choice === "required") return true; + if (!choice || typeof choice !== "object" || Array.isArray(choice)) return false; + const selector = choice as Record; + // Eligibility is broader than terminal inspection: hosted selectors retain their own terminal owner. + if (selector.type !== "allowed_tools") return knownToolSelector(selector); + return selector.mode === "required" + && Array.isArray(selector.tools) && selector.tools.length > 0 + && selector.tools.every(knownToolSelector); +} + +/** A translated Chat target must not turn a mandatory selector into auto or none. */ +export function chatCanPreserveMandatoryToolChoice(rawBody: unknown, provider: OcxProviderConfig, modelId: string): boolean { + if (!hasMandatoryToolChoice(rawBody)) return true; + if (modelInList(provider.autoToolChoiceOnlyModels, modelId)) return false; + const mapped = mapToolChoice((rawBody as Record).tool_choice); + return mapped === "required" || (!!mapped && typeof mapped === "object"); +} + const hostedCalls = new Map([ ["web_search", "web_search_call"], ["web_search_preview", "web_search_call"], ["file_search", "file_search_call"], ["code_interpreter", "code_interpreter_call"], diff --git a/src/server/responses/core-combo.ts b/src/server/responses/core-combo.ts index 3b8d0fde10c..20dae280c0a 100644 --- a/src/server/responses/core-combo.ts +++ b/src/server/responses/core-combo.ts @@ -1,6 +1,11 @@ import { effectiveAzureOutputBudget } from "../../adapters/azure"; +import { buildResponsesTransport } from "../../adapters/openai-responses/transport"; +import { isMetaAiResponsesDestination } from "../../responses/muse-tool-name-alias"; +import { chatCanPreserveMandatoryToolChoice, hasMandatoryToolChoice } from "./combo-required-tool-contract"; import { isCodexAccountCooldownResponse } from "../../codex/account-cooldown-response"; import { resolveWireProtocolOverride } from "../adapter-resolve"; +import { parseSyntheticRowId } from "../fast-row"; +import { resolveOpenAiVirtualModel } from "../../providers/openai-virtual-models"; import { comboCooldownRetryAfterSeconds, isDefiniteContextOverflow, type CodexAccountModelRefusal } from "../../combos/failover"; import { fallbackTriggerForStatus, guardFallbackHop } from "../../routing/fallback-policy"; import { @@ -56,7 +61,7 @@ import { previousResponseProviderState, } from "../../responses/state"; import { hasUnreadableEncryptedAgentTask } from "./encrypted-payload"; -import { routeConcreteModel, comboRouteDecisionTrace } from "../../router"; +import { captureRouteStaticPolicy, routeConcreteModel, comboRouteDecisionTrace } from "../../router"; import { memoryModelRouteReason } from "./memory-models"; import { baseProviderLabel, poolAccountProviderLabel } from "../../providers/label"; import { getAccountSet } from "../../oauth/store"; @@ -533,10 +538,31 @@ export async function executeComboResponses( const payloadEligible = (target: (typeof combo.targets)[number]): boolean => comboPayloadReadable || !unreadableEncryptedAgentTask || canDecryptUnreadableAgentTask(target); const meteredAllowed = admissionMayUseMeteredComboTargets(config, options.admission, combo); + const museToolEligible = (target: (typeof combo.targets)[number]): boolean => { + if (!hasMandatoryToolChoice(body)) return true; + try { + const selector = `${target.provider}/${target.model}`, { fastRow, effortRow } = parseSyntheticRowId(selector, config); + const route = routeConcreteModel(config, fastRow?.baseId ?? effortRow?.baseId ?? selector); + const modelId = resolveOpenAiVirtualModel(route.providerName, route.modelId)?.wireModelId ?? route.modelId; + const inboundWire = options.inboundWire ?? "responses"; + const policy = captureRouteStaticPolicy(route.providerName, modelId, route.provider, + route.staticPolicy.effectiveAlias, inboundWire); + const provider = resolveWireProtocolOverride(route.providerName, modelId, route.provider, inboundWire, policy); + return provider.adapter === "openai-chat" ? chatCanPreserveMandatoryToolChoice(body, provider, modelId) + : provider.adapter !== "openai-responses" || !isMetaAiResponsesDestination(buildResponsesTransport(provider, { headers: req.headers }).url); + } catch { + return true; + } + }; + const onlyMuseToolIncompatible = (): boolean => { + const enabled = combo.targets.filter(target => config.providers[target.provider]?.disabled !== true); + return enabled.length > 0 && enabled.every(target => !museToolEligible(target)); + }; // Recovery can replace unreadable encrypted input, but cannot relax other admission checks. const recoveryEligible = (target: (typeof combo.targets)[number]): boolean => (meteredAllowed || !target.metered) && (combo.strategy !== "jev" || target.provider !== JEV_PROVIDER_ID) + && museToolEligible(target) && reasoningReplayEligible(target) && (protocolLanes?.pickable(target) ?? true); const targetEligible = (target: (typeof combo.targets)[number]): boolean => @@ -651,6 +677,9 @@ export async function executeComboResponses( config, { parentThreadId: inboundClientThreadId }, ); + if (!options.abortSignal?.aborted && onlyMuseToolIncompatible()) { + return comboUnavailableResponse(`No available targets for combo: ${comboId}; targets cannot preserve mandatory tool_choice`); + } return options.abortSignal?.aborted ? clientCancelledResponse() : comboUnavailable(comboId, undefined, @@ -668,6 +697,9 @@ export async function executeComboResponses( const protocolRefusal = protocolLanes?.refusal(); if (protocolRefusal) return protocolRefusal; if (onlyReplayIncompatibleTargetsRemain()) return targetIncompatibleResponse(); + if (!options.abortSignal?.aborted && onlyMuseToolIncompatible()) { + return comboUnavailableResponse(`No available targets for combo: ${comboId}; targets cannot preserve mandatory tool_choice`); + } return options.abortSignal?.aborted ? clientCancelledResponse() : comboUnavailable(comboId, undefined, comboExclusionReasons(config, comboId, targetEligible)); diff --git a/src/server/responses/passthrough-dispatch.ts b/src/server/responses/passthrough-dispatch.ts index e253d5c9460..86d3afdcbea 100644 --- a/src/server/responses/passthrough-dispatch.ts +++ b/src/server/responses/passthrough-dispatch.ts @@ -54,6 +54,7 @@ import { import { restoreRoutedCustomCalls, RoutedCustomToolCompatError } from "../../responses/custom-tool-compat"; import { XaiToolSchemaCompatibilityError } from "../../adapters/xai-tool-schema"; import { MuseToolChoiceCompatibilityError } from "../../adapters/openai-responses/muse-tool-choice"; +import { MuseToolCatalogCompatibilityError } from "../../adapters/openai-responses/muse-tool-catalog"; import { OpenGatewayInputCompatibilityError } from "../../adapters/openai-responses/opengateway-input"; import { formatErrorResponse } from "../../bridge"; import { redactSecretString } from "../../lib/redact"; @@ -389,6 +390,7 @@ export async function preparePassthroughExchange( || error instanceof XaiToolSchemaCompatibilityError || error instanceof RoutedCustomToolCompatError || error instanceof MuseToolChoiceCompatibilityError + || error instanceof MuseToolCatalogCompatibilityError || error instanceof OpenGatewayInputCompatibilityError ) { return formatErrorResponse(400, "invalid_request_error", redactSecretString(error.message)); @@ -480,10 +482,9 @@ export async function preparePassthroughExchange( for (const callType of collectProviderExecutedCallTypes(clientToolAuthorizationBody)) { providerExecutedCallTypes.add(callType); } - const currentOutboundCatalog = currentTurnWireToolCatalogBody( - outboundRequestBody, - replayedInputPrefixLength, - ); + // Serialization drops provenance, so never slice transformed input by the raw prefix. + // Current wrappers were captured above; adapter-injected hosted tools are top-level. + const currentOutboundCatalog = { tools: outboundRequestBody?.tools }; for (const callType of collectProviderExecutedCallTypes(currentOutboundCatalog)) { providerExecutedCallTypes.add(callType); } diff --git a/src/server/responses/request-send-budget.ts b/src/server/responses/request-send-budget.ts index 27f1c5ac18e..0417a6adcf5 100644 --- a/src/server/responses/request-send-budget.ts +++ b/src/server/responses/request-send-budget.ts @@ -6,6 +6,7 @@ import { workflowSendCeilingReached, workflowSpendCeilingReached, } from "../../lib/workflow-budget"; +import { currentWorkflowBudgetPolicy } from "../../lib/workflow-budget-config"; import { poolContinuityRefusalReason, workflowRefusalResponse } from "../workflow-refusal"; import type { AttemptRecoveryKind, AttemptRecoveryWithheld } from "../../usage/log"; import { noteAttemptRecoveryWithheld, noteAttemptSend } from "../request-log"; @@ -93,7 +94,7 @@ export function createResponsesSendBudget( // dropping the record to make room would hand the fan-out a fresh allowance, which is the // laundering this ceiling exists to stop. The client is told the task needs a new grant // rather than being given a synthetic upstream error. - if (workflowSendCeilingReached(workflowRootId)) { + if (workflowSendCeilingReached(workflowRootId, currentWorkflowBudgetPolicy())) { // A log context exists here, unlike at HTTP admission, so the row this request writes is // marked synthetic rather than reading as a request that vanished with zero sends. return workflowRefusalResponse("workflow-sends-exhausted", logCtx, undefined, workflowRootId); diff --git a/src/server/workflow-refusal.ts b/src/server/workflow-refusal.ts index 58dab886a53..61c7b943b8f 100644 --- a/src/server/workflow-refusal.ts +++ b/src/server/workflow-refusal.ts @@ -27,6 +27,7 @@ import { type WorkflowLane, type WorkflowSpendDenialDetail, } from "../lib/workflow-budget"; +import { currentWorkflowBudgetPolicy } from "../lib/workflow-budget-config"; /** * What a caller needs to hand over for the refusal to become a row on `/api/logs`. @@ -142,7 +143,7 @@ export function admitHttpWorkflowTurn(headers: Headers): WorkflowDecision | unde const lane: WorkflowLane = rootId !== undefined && threadId !== undefined && threadId !== rootId ? "worker" : "interactive"; - return admitWorkflowTurn(rootId, lane, undefined, threadId); + return admitWorkflowTurn(rootId, lane, currentWorkflowBudgetPolicy(), threadId); } /** diff --git a/src/types/config.ts b/src/types/config.ts index b3ca6bd575c..10fcdcff171 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -770,6 +770,11 @@ export interface OcxConfig { * classification. Models no list names and no price covers are never proposed. */ codexRoleTiers?: { fast?: string[]; standard?: string[]; frontier?: string[] }; + /** + * Measured-capability source for Codex role auto-assign. With an Artificial Analysis API key + * (a literal, ${ENV} or keychain reference) its indexes inform measured tiers. + */ + codexRoleCapability?: { artificialAnalysis?: { apiKey?: string } }; /** * TTL (ms) for cached sub-agent model availability probes. Default 60_000. */ @@ -875,6 +880,8 @@ export interface OcxConfig { reasoningRetention?: { enabled?: boolean; maxContextPercent?: number; maxTokens?: number }; /** Canonical ChatGPT reasoning replay allowances; both default off. */ nativeReasoningRetention?: { modelSwitch?: boolean; accountSwitch?: boolean }; + /** Per-root fan-out ceilings (src/lib/workflow-budget.ts); omitted fields keep 8 / 256 / 64. */ + workflowBudget?: { maxConcurrentChildren?: number; maxPhysicalSends?: number; maxDistinctChildren?: number }; /** Explicit opt-in; resumes Observe only using an existing trusted certificate and endpoints. */ desktopCompatibility?: { startOnProxyStart: boolean }; /** diff --git a/src/types/provider.ts b/src/types/provider.ts index 61c66de5bc5..c2358cc46d7 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -434,6 +434,14 @@ export interface OcxProviderConfig { fastEnabled?: boolean; /** Exact upstream model ids that override the provider-level service-tier capability. */ modelSupportsServiceTier?: Record; + /** + * Whether to promote Codex Desktop's `additional_tools` input items into top-level `tools` + * and strip the wrapper from `input`. Enabled automatically for OpenCode Go and xAI + * (`api.x.ai` and `cli-chat-proxy.grok.com`) except on forward-auth routes, which need an + * explicit `true`; other strict gateways that return HTTP 422 for unknown item types in + * `input` can opt in here. + */ + promoteAdditionalTools?: boolean; /** * Responses upstream whose native contract accepts plaintext reasoning replay * (DeepSeek documents reasoning items with plaintext content). When set, the diff --git a/src/update/job.ts b/src/update/job.ts index d138bd56485..b5926fceb40 100644 --- a/src/update/job.ts +++ b/src/update/job.ts @@ -527,6 +527,26 @@ export function staleActiveUpdateJobReason( return null; } +/** + * Status view of an active record whose worker is gone (#7967). A worker killed with its service + * cgroup never writes a terminal state, so the persisted record stays `running`. The view reports + * it as `failed` with the reason so the dashboard stops waiting, without writing: the persisted + * record is settled only by the next run's existing stale recovery, which holds the start path, + * so a status poll can never overwrite a job another process started meanwhile. + */ +export function readUpdateJobForStatus( + jobId?: string | null, + now = Date.now(), + isAlive: (pid: number) => boolean = isProcessAlive, +): UpdateJobState | null { + const job = readUpdateJob(jobId); + if (!job) return null; + const reason = staleActiveUpdateJobReason(job, now, isAlive); + if (!reason) return job; + const line = `Update worker stopped before finishing: ${reason}.`; + return { ...job, status: "failed", error: line, exitCode: null, log: [...job.log, line] }; +} + /** * Spawn the GUI update worker without inheriting the proxy's LISTEN socket. * diff --git a/src/update/worker-launch.ts b/src/update/worker-launch.ts index 331e9431da0..24dd7d04b9f 100644 --- a/src/update/worker-launch.ts +++ b/src/update/worker-launch.ts @@ -1,5 +1,5 @@ import { probeSpawn, probeSpawnSync } from "./mutation-guard.mjs"; -import { accessSync, constants, realpathSync, statSync } from "node:fs"; +import { accessSync, constants, readFileSync, realpathSync, statSync } from "node:fs"; import { dirname, isAbsolute } from "node:path"; /** @@ -171,6 +171,76 @@ export function resetSystemdRunProbeForTests(): void { systemdRunProbePending = undefined; } +export interface DashboardRestartContext { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + /** The resolved scope launcher, or undefined when none is usable. */ + systemdRun: string | undefined; + /** The OpenCodex-managed user unit (`TASK` in src/service/state.ts) without `.service`. */ + unitName: string; + readCgroup?: () => string; +} + +function cgroupPaths(cgroup: string): string[] { + const rows = cgroup.split("\n").map(line => line.split(":")).filter(parts => parts.length >= 3); + // cgroup v2 ("0::") or the v1 systemd hierarchy name the unit; controller lines are noise. + const unified = rows.filter(parts => parts[1] === "" || parts[1] === "name=systemd"); + return (unified.length > 0 ? unified : rows).map(parts => parts.slice(2).join(":").trim()).filter(Boolean); +} + +/** The innermost systemd unit named by a cgroup path: its last `.service` or `.scope` component. */ +function innermostUnit(path: string): { unit: string; index: number; parts: string[] } | null { + const parts = path.split("/"); + for (let index = parts.length - 1; index >= 0; index--) { + const part = parts[index]!; + if (part.endsWith(".service") || part.endsWith(".scope")) return { unit: part, index, parts }; + } + return null; +} + +/** + * Why a dashboard update must not run against this proxy, or null when it may (#7967). + * + * A dashboard update stops the proxy before it replaces the package, with or without a requested + * restart, and the worker survives that stop only from its own transient scope. Only OpenCodex's + * own user unit is restarted by the updater. A proxy inside any systemd service -- detected from + * `INVOCATION_ID` or from a `.service` cgroup, so a unit that unsets the variable is still seen + * -- is therefore refused unless the scope launcher works and the cgroup places it in + * `.service` under a per-user manager (a delegated child cgroup below it is fine). A + * custom system unit with `User=` would lose the worker with its cgroup, and the restart would aim + * at an unrelated, possibly inactive user unit. Outside systemd nothing changes. + */ +export function dashboardRestartRefusal(context: DashboardRestartContext): string | null { + const platform = context.platform ?? process.platform; + const env = context.env ?? process.env; + if (platform !== "linux") return null; + let cgroup: string | null; + try { cgroup = (context.readCgroup ?? (() => readFileSync("/proc/self/cgroup", "utf8")))(); } + catch { cgroup = null; } + const paths = cgroup === null ? [] : cgroupPaths(cgroup); + // The innermost unit decides: a terminal or app scope below the per-user manager + // (user@.service/app.slice/vte-spawn-1.scope) is not a service; a system or user service is. + const units: ReturnType[] = paths.map(innermostUnit); + const observed = units.filter((found): found is NonNullable => found !== null); + // The observed cgroup wins over an inherited INVOCATION_ID (a shell moved into its own scope + // keeps the variable); without an observation the variable alone fails closed. + const inService = observed.length > 0 + ? observed.some(found => found.unit.endsWith(".service") && !found.unit.startsWith("user@")) + : Boolean(env.INVOCATION_ID); + if (!inService) return null; + const manual = "Stop the service with its own manager, update the package from a shell (for example `ocx update`), then start the service again."; + if (!context.systemdRun || !env.INVOCATION_ID) { + return `Dashboard update is unavailable: this proxy runs inside a systemd service, and the update worker cannot leave the service's cgroup (no usable \`systemd-run --user --scope\`), so stopping the proxy for the update would stop the update too. ${manual}`; + } + const ownUnit = `${context.unitName}.service`; + const managedByOwnUnit = units.some((found): boolean => found !== null && found.unit === ownUnit + && found.parts.slice(0, found.index).some((part: string) => part.startsWith("user@") && part.endsWith(".service"))); + if (!managedByOwnUnit) { + return `Dashboard update is unavailable: this proxy runs under a systemd unit that is not OpenCodex's own \`${context.unitName}.service\` user unit, so the updater can neither keep its worker alive nor restart that unit safely. ${manual}`; + } + return null; +} + export function guiUpdateWorkerCommand( execPath: string, args: readonly string[], diff --git a/src/web-search/parse.ts b/src/web-search/parse.ts index 699babde617..1420385b2a8 100644 --- a/src/web-search/parse.ts +++ b/src/web-search/parse.ts @@ -466,7 +466,8 @@ export async function parseSidecarSSE(response: Response): Promise