From 9534c0c94bbdb337a738704989cf7e8f813db47c Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 23 Oct 2026 18:17:55 +0900 Subject: [PATCH 001/101] fix(coding-agent): launch native Windows CLIs and refuse empty-arg-dropping dispatchers (#7527) * fix(coding-agent): launch native Windows CLIs and refuse empty-arg-dropping dispatchers On Windows, whichFromPath tried .cmd before .exe and resolveCodingAgentBinary returned the first candidate hit. The official Qoder CN layout puts the PowerShell dispatcher qodercn.cmd on PATH ahead of qoderclicn.exe, and the PowerShell hop drops empty argv entries, so --setting-sources "" swallowed --max-turns and every qoder-cn turn failed. - whichFromPath: a same-stem .exe beats the .cmd in the same PATH directory; PATH order still decides between directories. - resolveCodingAgentBinary: the first native hit among a profile's candidates wins over an earlier batch dispatcher; only native hits are cached. - turn preflight: a remaining .cmd/.bat that must carry an empty argument is read, and the turn is refused with cli_dispatcher_drops_empty_args before spawn when it forwards through powershell/pwsh or cannot be read. npm-style shims that forward %* directly keep working. Refs #7518 * fix(coding-agent): verify the whole batch dispatcher chain before trusting empty args Review follow-up for #7527: - a dispatcher larger than the 64 KiB inspection bound is refused as incomplete instead of approved on a truncated read; - a hop to another .cmd/.bat is followed only through a literal %~dp0 or absolute path, up to four files; a hop through a variable, a deeper chain, or an unreadable target is refused (CALL :label and native .exe targets stay allowed, so npm cmd-shims keep working); - the Windows native-receiver turns use only fixture PATH directories, so a Qoder CLI installed on the host cannot satisfy discovery. Refs #7518 * docs(structure): fold the Windows executable-discovery note into the Qoder paragraph to stay within the line budget --- .../src/content/docs/guides/providers.md | 1 + scripts/test-layout/layout.json | 2 +- src/adapters/coding-agent/batch-dispatcher.ts | 122 +++++++++ src/adapters/coding-agent/profile.ts | 70 +++-- src/adapters/coding-agent/turn.ts | 32 ++- structure/providers-and-adapters.md | 2 +- .../coding-agent-windows-dispatch.test.ts | 248 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 2 +- tests/providers/codebuddy-adapter.test.ts | 2 + .../coding-agent-native-empty-argv.test.ts | 116 ++++++++ 10 files changed, 576 insertions(+), 21 deletions(-) create mode 100644 src/adapters/coding-agent/batch-dispatcher.ts create mode 100644 tests/adapters/coding-agent-windows-dispatch.test.ts create mode 100644 tests/windows/coding-agent-native-empty-argv.test.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index a657f2e06e8..dbba5e75c70 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -1324,6 +1324,7 @@ OpenCodex provides official adapter support for Qoder through the `qoder` (Globa - **Authentication:** Create a PAT in the account integrations page ([Global](https://qoder.com/account/integrations), [CN](https://qoder.cn/account/integrations)) and paste it as the provider's API key. The stored key reaches the CLI only as `QODER_PERSONAL_ACCESS_TOKEN` (Global) or `QODERCN_PERSONAL_ACCESS_TOKEN` (CN) in a scoped child environment. - **Region Isolation:** Each preset accepts only its canonical destination (`https://qoder.com` or `https://qoder.cn`) and resolves its own executable. Credentials, model cache, usage, and health are independent; neither region falls back to the other. An older custom provider named `qoder` with a different destination keeps its existing adapter and URL. +- **Windows executables:** Each preset launches its CLI's native executable (`qoder.exe` or `qodercli.exe` for Global, `qodercn.exe` or `qoderclicn.exe` for CN) when one is on PATH, even if an installer's `.cmd` dispatcher such as `qodercn.cmd` is found first. Qoder turns pass empty `--tools` and `--setting-sources` values to keep the CLI's built-in tools and local settings out, and a dispatcher that forwards through PowerShell drops those empty values. When only such a dispatcher is found, the turn fails before the CLI starts with `cli_dispatcher_drops_empty_args`; add the folder holding the native executable to PATH (the official CN installer places it in `%USERPROFILE%\.qoder-cn\bin\qoderclicn`) and retry. A dispatcher that passes arguments straight through, such as an npm shim, keeps working. - **Model Discovery:** `qoder --list-models` is the authoritative entitlement roster for the current PAT. The cache is bound to an irreversible fingerprint of the token, so switching accounts never reuses another account's roster. If discovery fails, the provider degrades to a stale cache and then the documented static seed. - **Token usage:** Qoder CLI reports zero token counts unless its usage switch is on, so each preset turns it on in its scoped CLI child environment: `QODER_EXPOSE_TOKEN_USAGE=true` for Global and `QODERCN_EXPOSE_TOKEN_USAGE=true` for CN, which reads only its own prefix. - **Tool Ownership:** The CLI runs single-turn `stream-json` with `--tools ""`, `--strict-mcp-config`, setting sources disabled, and session persistence disabled, so Codex keeps exclusive tool ownership. Client-declared tools are exposed through an isolated request-scoped MCP server. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index b8306d664dd..e2ec2449dfd 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"], - "explicit": { "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", + "explicit": { "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", "service-proxy-env.test.ts": "service", "openai-chat-standalone-bare-block.test.ts": "adapters/openai", "owner-registry-isolation.test.ts": "config", "responses-auto-pause-replay-owner.test.ts": "responses", "responses-account-auto-pause.test.ts": "responses", "oauth-account-auto-pause.test.ts": "oauth", "responses-account-auto-pause-wire-owner.test.ts": "responses", "oauth-upsert-paused-api-key.test.ts": "oauth", "management-account-auto-pause.test.ts": "server", "cli-account-key-pause.test.ts": "cli", "oauth-account-auto-pause-revision.test.ts": "oauth", "config-account-auto-pause-revisions.test.ts": "config", "codex-history-pending-wal.test.ts": "codex-integration", "responses-anthropic-billing-send-budget.test.ts": "responses", "anthropic-billing-ownership.test.ts": "adapters/anthropic", "server-anthropic-billing.test.ts": "server", "server-anthropic-billing-boundaries.test.ts": "server", "router-anthropic-billing-failover.test.ts": "routing", "anthropic-billing-refusal.test.ts": "adapters/anthropic", "messages-request-id-headers.test.ts": "claude-integration", "bounded-file-read.test.ts": "lib", "responses-undeclared-tool-guard-inbound.test.ts": "responses", "explicit-empty-tool-catalog.test.ts": "responses", "explicit-empty-tool-catalog-history.test.ts": "responses", diff --git a/src/adapters/coding-agent/batch-dispatcher.ts b/src/adapters/coding-agent/batch-dispatcher.ts new file mode 100644 index 00000000000..e8da61fe03e --- /dev/null +++ b/src/adapters/coding-agent/batch-dispatcher.ts @@ -0,0 +1,122 @@ +import { closeSync, openSync, readSync } from "node:fs"; +import { posix, win32 } from "node:path"; + +/** Dispatchers are a few lines; a larger file is not inspected and so not trusted. */ +const MAX_DISPATCHER_BYTES = 64 * 1024; +/** Launcher hops followed before the chain is reported as unverifiable. */ +const MAX_DISPATCHER_DEPTH = 4; + +/** Thrown by the default reader when a dispatcher is larger than the inspection bound. */ +export class DispatcherTooLargeError extends Error {} + +/** Returns a dispatcher's full text; throws when it cannot be read completely. */ +export type ReadDispatcherFn = (path: string) => string; + +export type BatchDispatcherVerdict = + | { preservesEmptyArguments: true } + | { preservesEmptyArguments: false; reason: "powershell_hop" | "unreadable" | "incomplete" | "indirect_launch" }; + +/** Read the whole dispatcher, refusing one that does not fit the inspection bound. */ +function readWholeDispatcher(path: string): string { + const fd = openSync(path, "r"); + try { + // One byte past the bound tells a complete read from a truncated one. + const buffer = Buffer.alloc(MAX_DISPATCHER_BYTES + 1); + let filled = 0; + while (filled < buffer.length) { + const bytes = readSync(fd, buffer, filled, buffer.length - filled, filled); + if (bytes === 0) break; + filled += bytes; + } + if (filled > MAX_DISPATCHER_BYTES) throw new DispatcherTooLargeError(path); + return buffer.subarray(0, filled).toString("latin1"); + } finally { + closeSync(fd); + } +} + +/** `REM ...` and `:: ...` lines are comments; a mention there launches nothing. */ +const COMMENT_LINE = /^\s*(?:@?rem(?:\s|$)|::)/i; +/** `%~dp0` expands to the dispatcher's own directory with a trailing separator. */ +const POWERSHELL_HOP = /(?:^|[\s"'(&|@\\/]|%~dp0)(?:powershell|pwsh)(?:\.exe)?(?=[\s"')&|]|$)/i; +/** A quoted or bare token naming another batch file. */ +const BATCH_REFERENCE = /"([^"]+\.(?:cmd|bat))"|([^\s"'&|<>()]+\.(?:cmd|bat))(?=[\s"'&|<>()]|$)/gi; +/** The target of a CALL statement. */ +const CALL_TARGET = /(?:^|[\s&|(@])call\s+("[^"]*"|[^\s&|<>()]+)/gi; + +function unquote(token: string): string { + return token.replace(/^"|"$/g, ""); +} + +/** + * Resolve a referenced batch file to a literal path: relative to this dispatcher through `%~dp0`, + * or already absolute. Anything still carrying a variable cannot be verified. + */ +function resolveReference(reference: string, dispatcher: string): string | undefined { + const pathApi = dispatcher.includes("\\") ? win32 : posix; + const expanded = /^%~dp0/i.test(reference) + ? pathApi.join(pathApi.dirname(dispatcher), reference.replace(/^%~dp0[\\/]?/i, "")) + : reference; + if (expanded.includes("%") || expanded.includes("!")) return undefined; + return win32.isAbsolute(expanded) || posix.isAbsolute(expanded) ? expanded : undefined; +} + +type LineVerdict = { kind: "powershell" } | { kind: "unknown" } | { kind: "batches"; paths: string[] }; + +function inspectLine(line: string, dispatcher: string): LineVerdict { + if (POWERSHELL_HOP.test(line)) return { kind: "powershell" }; + const paths: string[] = []; + for (const match of line.matchAll(CALL_TARGET)) { + const target = unquote(match[1] ?? ""); + // CALL :label stays inside this file; a CALL of a native image keeps argv boundaries. + if (target.startsWith(":") || /\.(?:exe|com)$/i.test(target)) continue; + if (!/\.(?:cmd|bat)$/i.test(target)) return { kind: "unknown" }; + } + for (const match of line.matchAll(BATCH_REFERENCE)) { + const resolved = resolveReference(match[1] ?? match[2] ?? "", dispatcher); + if (!resolved) return { kind: "unknown" }; + paths.push(resolved); + } + return { kind: "batches", paths }; +} + +/** + * Can this .cmd/.bat dispatcher deliver an empty-string argument to the CLI it launches? + * + * cmd.exe hands "" to the batch file intact, and a dispatcher that forwards %* straight to a + * native image or to node (the npm cmd-shim layout) delivers it as an empty argv entry. A + * dispatcher that forwards through "powershell -File ... %*" does not: the PowerShell hop drops + * the empty values, so --setting-sources "" --max-turns 1 arrives as + * --setting-sources --max-turns 1 (#7518). + * + * The bytes of the dispatcher about to be run are the evidence; nothing is guessed from install + * paths. A positive verdict needs the whole chain inspected: every file read completely, and every + * hop to another batch file resolved to a literal path and inspected in turn, up to + * `MAX_DISPATCHER_DEPTH` files. An unreadable or oversized file, a hop through a variable, or a chain + * deeper than the bound proves nothing and is refused. + */ +export function inspectBatchDispatcher(path: string, read: ReadDispatcherFn = readWholeDispatcher): BatchDispatcherVerdict { + const seen = new Set(); + const pending = [path]; + while (pending.length > 0) { + const current = pending.shift()!; + const key = current.toLowerCase(); + if (seen.has(key)) continue; + if (seen.size >= MAX_DISPATCHER_DEPTH) return { preservesEmptyArguments: false, reason: "indirect_launch" }; + seen.add(key); + let body: string; + try { + body = read(current); + } catch (error) { + return { preservesEmptyArguments: false, reason: error instanceof DispatcherTooLargeError ? "incomplete" : "unreadable" }; + } + for (const line of body.split(/\r?\n/)) { + if (COMMENT_LINE.test(line)) continue; + const verdict = inspectLine(line, current); + if (verdict.kind === "powershell") return { preservesEmptyArguments: false, reason: "powershell_hop" }; + if (verdict.kind === "unknown") return { preservesEmptyArguments: false, reason: "indirect_launch" }; + pending.push(...verdict.paths); + } + } + return { preservesEmptyArguments: true }; +} diff --git a/src/adapters/coding-agent/profile.ts b/src/adapters/coding-agent/profile.ts index d1f9d0bd33e..c799ceada3e 100644 --- a/src/adapters/coding-agent/profile.ts +++ b/src/adapters/coding-agent/profile.ts @@ -1,5 +1,5 @@ import { existsSync } from "node:fs"; -import { delimiter, join } from "node:path"; +import { posix, win32 } from "node:path"; /** * One region-isolated official coding-agent CLI target (§三十一). @@ -45,6 +45,26 @@ export interface CodingAgentProviderProfile { /** Test seam: report the resolved path of a candidate executable, or undefined. */ export type WhichFn = (candidate: string) => string | undefined; +/** Test seams for {@link whichFromPath}; production reads the live process environment. */ +export interface PathLookupDeps { + env?: Record; + platform?: NodeJS.Platform; + exists?: (path: string) => boolean; +} + +/** + * Windows extension order inside ONE PATH directory. A native image comes before a batch + * dispatcher with the same stem, matching the system's own PATHEXT order (.EXE before .BAT/.CMD). + * A `.cmd` re-parses argv through cmd.exe and whatever it forwards to, and a vendor dispatcher + * that hops through PowerShell drops empty-string arguments (#7518). + */ +const WINDOWS_PATH_EXTENSIONS = [".exe", ".cmd", ".bat", ""] as const; + +/** A `.cmd`/`.bat` target: launched through cmd.exe rather than as a native image. */ +export function isBatchDispatcher(path: string): boolean { + return /\.(?:cmd|bat)$/i.test(path); +} + const binaryCache = new Map(); /** Reset the discovery cache (tests, or an explicit provider re-check). */ @@ -52,17 +72,26 @@ export function clearCodingAgentBinaryCache(): void { binaryCache.clear(); } -/** Default PATH scan: return the first existing executable path for a candidate name. */ -export function whichFromPath(candidate: string): string | undefined { - const pathVar = process.env.PATH ?? ""; +/** + * Default PATH scan: return the first existing executable path for a candidate name. + * + * PATH order owns the choice between directories: an earlier directory wins even when a later one + * holds a native build of the same name. Only inside the winning directory does a same-stem `.exe` + * beat the `.cmd` beside it. + */ +export function whichFromPath(candidate: string, deps: PathLookupDeps = {}): string | undefined { + const platform = deps.platform ?? process.platform; + const exists = deps.exists ?? existsSync; + const pathApi = platform === "win32" ? win32 : posix; + const pathVar = (deps.env ?? process.env).PATH ?? ""; if (!pathVar) return undefined; - const extensions = process.platform === "win32" ? [".cmd", ".exe", ".bat", ""] : [""]; - for (const dir of pathVar.split(delimiter)) { + const extensions = platform === "win32" ? WINDOWS_PATH_EXTENSIONS : [""]; + for (const dir of pathVar.split(pathApi.delimiter)) { if (!dir) continue; for (const ext of extensions) { - const full = join(dir, `${candidate}${ext}`); + const full = pathApi.join(dir, `${candidate}${ext}`); try { - if (existsSync(full)) return full; + if (exists(full)) return full; } catch { // An unreadable PATH entry must not abort discovery; skip it. } @@ -73,24 +102,33 @@ export function whichFromPath(candidate: string): string | undefined { /** * Discover the CLI executable BEFORE a request is sent (§二十六), so a missing CLI is a clear - * pre-flight error rather than a mid-turn ENOENT. Only positive hits are cached (§三十): a CLI - * installed after startup is found on the next turn instead of being masked by a cached negative. + * pre-flight error rather than a mid-turn ENOENT. + * + * Candidates are the profile's own names for one CLI, in preference order. The first native hit + * wins, so a vendor layout that puts a `qodercn.cmd` dispatcher and the real `qoderclicn.exe` on + * PATH launches the executable (#7518). When every hit is a batch dispatcher, the first one is + * returned and the turn decides whether it can carry the arguments. + * + * Only native hits are cached (§三十). A miss or a dispatcher-only result is resolved again on the + * next turn, so a CLI or native build installed after startup is picked up without a restart. */ export function resolveCodingAgentBinary( profile: CodingAgentProviderProfile, which: WhichFn = whichFromPath, ): string | undefined { + const cached = binaryCache.get(profile.providerId); + if (cached) return cached; + let dispatcher: string | undefined; for (const candidate of profile.binaryCandidates) { - const cacheKey = `${profile.providerId}:${candidate}`; - const cached = binaryCache.get(cacheKey); - if (cached) return cached; const resolved = which(candidate); - if (resolved) { - binaryCache.set(cacheKey, resolved); + if (!resolved) continue; + if (!isBatchDispatcher(resolved)) { + binaryCache.set(profile.providerId, resolved); return resolved; } + dispatcher ??= resolved; } - return undefined; + return dispatcher; } /** diff --git a/src/adapters/coding-agent/turn.ts b/src/adapters/coding-agent/turn.ts index 5b5faf966ea..831179f1e76 100644 --- a/src/adapters/coding-agent/turn.ts +++ b/src/adapters/coding-agent/turn.ts @@ -1,7 +1,7 @@ import { execFileSync, spawn as nodeSpawn, type ChildProcess, type SpawnOptions } from "node:child_process"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, win32 } from "node:path"; import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../types"; import { isTranslatorBudgetExceededError } from "../../lib/translator-budget"; import { SendBudgetExhaustedError } from "../../lib/upstream-retry"; @@ -21,7 +21,8 @@ import { toolBridgeInitError, type StreamParseState, } from "./protocol"; -import { resolveCodingAgentBinary, resolveProfileByBaseUrl, type CodingAgentProviderProfile, type WhichFn } from "./profile"; +import { isBatchDispatcher, resolveCodingAgentBinary, resolveProfileByBaseUrl, type CodingAgentProviderProfile, type WhichFn } from "./profile"; +import { inspectBatchDispatcher, type ReadDispatcherFn } from "./batch-dispatcher"; /** Injectable spawn for tests; production uses node:child_process. */ export type SpawnFn = (command: string, args: readonly string[], options: SpawnOptions) => ChildProcess; @@ -41,6 +42,8 @@ export interface CodingAgentDeps { reapTimeoutMs?: number; /** Test seam for Windows command-shim invocation. */ platform?: NodeJS.Platform; + /** Test seam for reading a Windows batch dispatcher before it is trusted with empty arguments. */ + readDispatcher?: ReadDispatcherFn; /** Test seam for terminating a Windows CLI and all descendants. */ killWindowsProcessTree?: KillWindowsProcessTreeFn; /** Test seam for a catalog/config write failure after private bridge-directory creation. */ @@ -301,6 +304,31 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise `${candidate}.exe`).join(" or "); + const cause = verdict.reason === "powershell_hop" + ? "forwards arguments through PowerShell, which drops the empty values" + : verdict.reason === "indirect_launch" + ? "hands off to another launcher that could not be checked for the empty values" + : "could not be read completely to confirm it keeps the empty values"; + emit({ + type: "error", + message: `${profile.label} CLI resolved to the batch dispatcher ${win32.basename(binary)}, which ${cause} this provider passes for tool and settings isolation. Put the CLI's native executable (${natives}) on PATH.`, + status: 500, + errorType: "upstream_error", + code: "cli_dispatcher_drops_empty_args", + retryable: false, + }); + if (toolBridgeDir) await rm(toolBridgeDir, { recursive: true, force: true }).catch(() => undefined); + return; + } + } if (toolBridge && toolBridgeMcpConfigPath) { // Exact names close the wildcard domain; --strict-mcp-config (family args) keeps user // servers out, so this isolated server is the only capability this turn can reach. diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index df5e3e01c37..a81d0790042 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -75,7 +75,7 @@ name/namespace components and wire names over 512 UTF-8 bytes before staging. It 256 KiB per serialized definition, and 2 MiB for the serialized array (including brackets and separators); the comma-joined `--allowed-tools` argument is separately capped at 8 KiB of UTF-8, counting full `mcp____` names and commas. Invalid catalogs return a fixed local 400 without spawning Qoder. Valid wire identities, order and schemas are unchanged, apart from the Responses-only encrypted marker stripped during projection. Duplicate and ambiguous wire identities remain the Responses parser's responsibility. Qoder appends the -folded system prompt through its documented scoped `QODER_APPEND_SYSTEM_PROMPT` or `QODERCN_APPEND_SYSTEM_PROMPT` child environment, never through command-line arguments or inherited vendor variables. `src/adapters/qoder/adapter.ts` sets the region's usage switch (`QODER_EXPOSE_TOKEN_USAGE` or `QODERCN_EXPOSE_TOKEN_USAGE`; each CLI build reads only its own prefix) to `true` only in Qoder's child environment, so the shared usage parser receives the CLI's token counts instead of zeros. +folded system prompt through its documented scoped `QODER_APPEND_SYSTEM_PROMPT` or `QODERCN_APPEND_SYSTEM_PROMPT` child environment, never through command-line arguments or inherited vendor variables. `src/adapters/qoder/adapter.ts` sets the region's usage switch (`QODER_EXPOSE_TOKEN_USAGE` or `QODERCN_EXPOSE_TOKEN_USAGE`; each CLI build reads only its own prefix) to `true` only in Qoder's child environment, so the shared usage parser receives the CLI's token counts instead of zeros. Executable discovery lives in `src/adapters/coding-agent/profile.ts`. On Windows a PATH directory's same-stem `.exe` beats its `.cmd`, while PATH order still decides between directories. Across a profile's own candidate names the first native hit wins over an earlier batch dispatcher, so the official Qoder CN layout (`qodercn.cmd` forwarding through PowerShell, `qoderclicn.exe` beside it on PATH) launches the executable. Only native hits are cached; a dispatcher-only result is resolved again on the next turn. When a turn must still launch a `.cmd`/`.bat` and its arguments carry an empty value (`--tools ""`, `--setting-sources ""`), `src/adapters/coding-agent/batch-dispatcher.ts` reads the dispatcher, and `turn.ts` refuses with `cli_dispatcher_drops_empty_args` before spawn unless the whole chain is verified: every file read completely within 64 KiB, every hop to another batch file resolved to a literal `%~dp0` or absolute path and inspected in turn (at most four files), and no file forwarding through `powershell`/`pwsh`. A hop through a variable, an oversized or unreadable file, or a deeper chain is refused. A dispatcher that forwards `%*` directly, such as an npm cmd-shim, keeps launching through cmd.exe. `tests/windows/coding-agent-native-empty-argv.test.ts` proves both paths with a native receiver on Windows. Coding-agent stdout is framed as bounded JSONL directly from decoded stream segments. The framer tracks the current line's UTF-8 byte count incrementally, searches each decoded segment once, and diff --git a/tests/adapters/coding-agent-windows-dispatch.test.ts b/tests/adapters/coding-agent-windows-dispatch.test.ts new file mode 100644 index 00000000000..f227b4cf617 --- /dev/null +++ b/tests/adapters/coding-agent-windows-dispatch.test.ts @@ -0,0 +1,248 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { EventEmitter } from "node:events"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PassThrough, Readable, Writable } from "node:stream"; +import type { ChildProcess } from "node:child_process"; +import { inspectBatchDispatcher } from "../../src/adapters/coding-agent/batch-dispatcher"; +import { clearCodingAgentBinaryCache, resolveCodingAgentBinary, whichFromPath } from "../../src/adapters/coding-agent/profile"; +import type { SpawnFn } from "../../src/adapters/coding-agent/turn"; +import { createQoderAdapter } from "../../src/adapters/qoder/adapter"; +import { QODER_CN_PROFILE, QODER_GLOBAL_PROFILE } from "../../src/adapters/qoder/profiles"; +import { CLAUDE_CLI_PROFILE } from "../../src/adapters/claude-cli/profiles"; +import { createClaudeCliAdapter } from "../../src/adapters/claude-cli/adapter"; +import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../src/types"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; + +// #7518: the official Qoder CN installer puts entry\qodercn.cmd (a PowerShell dispatcher) and +// bin\qoderclicn\qoderclicn.exe on PATH. The dispatcher drops empty argv entries, so +// --setting-sources "" swallowed --max-turns and every turn failed. +const VENDOR_DISPATCHER = '@echo off\r\npowershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0qodercn-dispatcher.ps1" -- %*\r\n'; +const NPM_CMD_SHIM = [ + "@ECHO off", "GOTO start", ":find_dp0", "SET dp0=%~dp0", "EXIT /b", ":start", "SETLOCAL", "CALL :find_dp0", "", + 'IF EXIST "%dp0%\\node.exe" (', ' SET "_prog=%dp0%\\node.exe"', ") ELSE (", ' SET "_prog=node"', " SET PATHEXT=%PATHEXT:;.JS;=;%", ")", "", + 'endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\node_modules\\@anthropic-ai\\claude-code\\cli.js" %*', +].join("\r\n"); + +function windowsFs(files: readonly string[]) { + const present = new Set(files.map(file => file.toLowerCase())); + return (path: string) => present.has(path.toLowerCase()); +} + +describe("whichFromPath on Windows", () => { + const env = { PATH: "C:\\entry;C:\\bin" }; + + test("a same-stem .exe beats the .cmd beside it", () => { + const exists = windowsFs(["C:\\entry\\qoder.cmd", "C:\\entry\\qoder.exe"]); + expect(whichFromPath("qoder", { env, platform: "win32", exists })).toBe("C:\\entry\\qoder.exe"); + }); + + test("PATH order owns the choice between competing directories", () => { + const exists = windowsFs(["C:\\entry\\qoder.cmd", "C:\\bin\\qoder.exe"]); + expect(whichFromPath("qoder", { env, platform: "win32", exists })).toBe("C:\\entry\\qoder.cmd"); + expect(whichFromPath("qoder", { env: { PATH: "C:\\bin;C:\\entry" }, platform: "win32", exists })).toBe("C:\\bin\\qoder.exe"); + }); + + test("POSIX lookup is unchanged and never appends Windows extensions", () => { + const probed: string[] = []; + const exists = (path: string) => { probed.push(path); return path === "/usr/local/bin/qoder"; }; + expect(whichFromPath("qoder", { env: { PATH: "/usr/bin:/usr/local/bin" }, platform: "linux", exists })).toBe("/usr/local/bin/qoder"); + expect(probed).toEqual(["/usr/bin/qoder", "/usr/local/bin/qoder"]); + }); +}); + +describe("resolveCodingAgentBinary profile resolution", () => { + beforeEach(() => clearCodingAgentBinaryCache()); + + function lookup(files: readonly string[], PATH: string) { + const exists = windowsFs(files); + const calls: string[] = []; + const which = (candidate: string) => { calls.push(candidate); return whichFromPath(candidate, { env: { PATH }, platform: "win32", exists }); }; + return { which, calls }; + } + + test("CN aliases: the vendor dispatcher qodercn.cmd yields to the native qoderclicn.exe", () => { + const { which } = lookup(["C:\\q\\entry\\qodercn.cmd", "C:\\q\\bin\\qoderclicn\\qoderclicn.exe"], "C:\\q\\entry;C:\\q\\bin\\qoderclicn"); + expect(resolveCodingAgentBinary(QODER_CN_PROFILE, which)).toBe("C:\\q\\bin\\qoderclicn\\qoderclicn.exe"); + }); + + test("Global aliases resolve the same way", () => { + const { which } = lookup(["C:\\q\\entry\\qoder.cmd", "C:\\q\\bin\\qodercli\\qodercli.exe"], "C:\\q\\entry;C:\\q\\bin\\qodercli"); + expect(resolveCodingAgentBinary(QODER_GLOBAL_PROFILE, which)).toBe("C:\\q\\bin\\qodercli\\qodercli.exe"); + }); + + test("a native first candidate is returned without probing the others", () => { + const { which, calls } = lookup(["C:\\q\\qodercn.exe", "C:\\q\\qoderclicn.exe"], "C:\\q"); + expect(resolveCodingAgentBinary(QODER_CN_PROFILE, which)).toBe("C:\\q\\qodercn.exe"); + expect(calls).toEqual(["qodercn"]); + }); + + test("a dispatcher-only install returns the first dispatcher", () => { + const { which } = lookup(["C:\\q\\entry\\qodercn.cmd", "C:\\npm\\qoderclicn.cmd"], "C:\\q\\entry;C:\\npm"); + expect(resolveCodingAgentBinary(QODER_CN_PROFILE, which)).toBe("C:\\q\\entry\\qodercn.cmd"); + }); + + test("an explicit resolver selection is used as returned", () => { + expect(resolveCodingAgentBinary(QODER_CN_PROFILE, () => "D:\\chosen\\qodercn.exe")).toBe("D:\\chosen\\qodercn.exe"); + clearCodingAgentBinaryCache(); + expect(resolveCodingAgentBinary(CLAUDE_CLI_PROFILE, () => "D:\\npm\\claude.cmd")).toBe("D:\\npm\\claude.cmd"); + }); + + test("cached discovery: a dispatcher-only result is not pinned, a native hit is", () => { + const files = ["C:\\q\\entry\\qodercn.cmd"]; + const exists = (path: string) => windowsFs(files)(path); + const which = (candidate: string) => whichFromPath(candidate, { env: { PATH: "C:\\q\\entry;C:\\q\\bin" }, platform: "win32", exists }); + expect(resolveCodingAgentBinary(QODER_CN_PROFILE, which)).toBe("C:\\q\\entry\\qodercn.cmd"); + // The native build is installed after the first turn; the next turn finds it without a restart. + files.push("C:\\q\\bin\\qoderclicn.exe"); + expect(resolveCodingAgentBinary(QODER_CN_PROFILE, which)).toBe("C:\\q\\bin\\qoderclicn.exe"); + // Positive native hits stay cached until an explicit re-check. + files.length = 0; + expect(resolveCodingAgentBinary(QODER_CN_PROFILE, which)).toBe("C:\\q\\bin\\qoderclicn.exe"); + clearCodingAgentBinaryCache(); + expect(resolveCodingAgentBinary(QODER_CN_PROFILE, which)).toBeUndefined(); + }); +}); + +describe("inspectBatchDispatcher", () => { + test("a PowerShell hop cannot carry empty arguments", () => { + expect(inspectBatchDispatcher("x.cmd", () => VENDOR_DISPATCHER)).toEqual({ preservesEmptyArguments: false, reason: "powershell_hop" }); + expect(inspectBatchDispatcher("x.cmd", () => '@"%~dp0pwsh" -File a.ps1 %*')).toEqual({ preservesEmptyArguments: false, reason: "powershell_hop" }); + }); + + test("an npm cmd-shim and a comment that merely names PowerShell forward %* intact", () => { + expect(inspectBatchDispatcher("x.cmd", () => NPM_CMD_SHIM)).toEqual({ preservesEmptyArguments: true }); + expect(inspectBatchDispatcher("x.cmd", () => 'REM not a powershell hop\r\n:: pwsh.exe either\r\n"%~dp0real.exe" %*')).toEqual({ preservesEmptyArguments: true }); + }); + + test("an unreadable dispatcher is not trusted", () => { + expect(inspectBatchDispatcher("x.cmd", () => { throw new Error("EACCES"); })).toEqual({ preservesEmptyArguments: false, reason: "unreadable" }); + }); +}); + +describe("inspectBatchDispatcher with real files", () => { + let dir: string; + beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "ocx-dispatcher-chain-")); }); + afterEach(() => rmSync(dir, { recursive: true, force: true })); + const write = (name: string, body: string) => { const path = join(dir, name); writeFileSync(path, body); return path; }; + + test("a PowerShell hop past the read bound is refused as incomplete", () => { + const padded = write("padded.cmd", "REM " + "x".repeat(64 * 1024) + "\r\npowershell.exe -File \"%~dp0d.ps1\" -- %*\r\n"); + expect(inspectBatchDispatcher(padded)).toEqual({ preservesEmptyArguments: false, reason: "incomplete" }); + const oversizedClean = write("big.cmd", "REM " + "x".repeat(64 * 1024) + "\r\n\"%~dp0real.exe\" %*\r\n"); + expect(inspectBatchDispatcher(oversizedClean)).toEqual({ preservesEmptyArguments: false, reason: "incomplete" }); + expect(inspectBatchDispatcher(write("small.cmd", NPM_CMD_SHIM))).toEqual({ preservesEmptyArguments: true }); + }); + + test("a hop to another batch file is followed, and its PowerShell forward is found", () => { + write("inner.cmd", VENDOR_DISPATCHER); + expect(inspectBatchDispatcher(write("outer.cmd", '@call "%~dp0inner.cmd" %*\r\n'))).toEqual({ preservesEmptyArguments: false, reason: "powershell_hop" }); + expect(inspectBatchDispatcher(write("direct.cmd", '@"%~dp0inner.cmd" %*\r\n'))).toEqual({ preservesEmptyArguments: false, reason: "powershell_hop" }); + }); + + test("a verified chain that ends in a native image keeps the empty values", () => { + write("inner.cmd", '@"%~dp0real.exe" %*\r\n'); + expect(inspectBatchDispatcher(write("outer.cmd", '@call "%~dp0inner.cmd" %*\r\n'))).toEqual({ preservesEmptyArguments: true }); + expect(inspectBatchDispatcher(write("exe.cmd", '@call "%~dp0real.exe" %*\r\n'))).toEqual({ preservesEmptyArguments: true }); + }); + + test("an unverifiable or missing hop is refused", () => { + expect(inspectBatchDispatcher(write("var.cmd", "@call %LAUNCHER% %*\r\n"))).toEqual({ preservesEmptyArguments: false, reason: "indirect_launch" }); + expect(inspectBatchDispatcher(write("rel.cmd", '@call "inner.cmd" %*\r\n'))).toEqual({ preservesEmptyArguments: false, reason: "indirect_launch" }); + expect(inspectBatchDispatcher(write("missing.cmd", '@call "%~dp0absent.cmd" %*\r\n'))).toEqual({ preservesEmptyArguments: false, reason: "unreadable" }); + }); + + test("a self-reference is not re-read, and a chain deeper than the bound is refused", () => { + expect(inspectBatchDispatcher(write("self.cmd", '@if "%1"=="" "%~dp0self.cmd" --help\r\n@"%~dp0real.exe" %*\r\n'))).toEqual({ preservesEmptyArguments: true }); + for (let index = 0; index < 5; index++) write("hop" + index + ".cmd", '@call "%~dp0hop' + (index + 1) + '.cmd" %*\r\n'); + write("hop5.cmd", '@"%~dp0real.exe" %*\r\n'); + expect(inspectBatchDispatcher(join(dir, "hop0.cmd"))).toEqual({ preservesEmptyArguments: false, reason: "indirect_launch" }); + }); +}); + +function resultChild(): ChildProcess { + const child = new EventEmitter() as ChildProcess & { killed: boolean; exitCode: number | null }; + const stdout = new PassThrough(); + stdout.end('{"type":"result","subtype":"success","is_error":false}\n'); + child.stdout = stdout; + child.stderr = Readable.from([]); + child.stdin = new Writable({ write(_chunk, _encoding, callback) { callback(); } }); + child.killed = false; + child.exitCode = null; + child.kill = () => { child.killed = true; return true; }; + setTimeout(() => { child.exitCode = 0; child.emit("close", 0); }, 2); + return child; +} + +describe("Windows turn preflight for empty isolation arguments", () => { + let dir: string; + beforeEach(() => { + clearCodingAgentBinaryCache(); + dir = mkdtempSync(join(tmpdir(), "ocx-dispatcher-")); + }); + afterEach(() => rmSync(dir, { recursive: true, force: true })); + + const parsed = { modelId: "Qwen3.8-Flash", stream: true, options: {}, context: { messages: [{ role: "user", content: "hello", timestamp: 0 }] } } as unknown as OcxParsedRequest; + const qoderCn = { adapter: "qoder", baseUrl: "https://qoder.cn", apiKey: "cn-pat" } as OcxProviderConfig; + + async function run(adapter: ReturnType) { + const events: AdapterEvent[] = []; + await adapter.runTurn!(parsed, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + return events; + } + + test("a .cmd-only Qoder CN install is refused before spawn", async () => { + const dispatcher = join(dir, "qodercn.cmd"); + writeFileSync(dispatcher, VENDOR_DISPATCHER); + let spawned = 0; + const events = await run(createQoderAdapter(qoderCn, { + platform: "win32", + which: candidate => candidate === "qodercn" ? dispatcher : undefined, + spawn: () => { spawned++; return resultChild(); }, + })); + expect(spawned).toBe(0); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: "error", code: "cli_dispatcher_drops_empty_args", retryable: false }); + const message = (events[0] as { message: string }).message; + expect(message).toContain("qodercn.cmd"); + expect(message).toContain("qodercn.exe or qoderclicn.exe"); + expect(message).not.toContain(dir); + }); + + test("with the native executable on PATH the turn spawns it directly with both empty values", async () => { + const calls: Array<{ file: string; args: readonly string[] }> = []; + const spawn: SpawnFn = (file, args) => { calls.push({ file, args }); return resultChild(); }; + await run(createQoderAdapter(qoderCn, { + platform: "win32", + which: candidate => candidate === "qodercn" ? "C:\\q\\entry\\qodercn.cmd" : "C:\\q\\bin\\qoderclicn.exe", + readDispatcher: () => { throw new Error("the native executable must not need a dispatcher read"); }, + spawn, + })); + expect(calls).toHaveLength(1); + expect(calls[0]!.file).toBe("C:\\q\\bin\\qoderclicn.exe"); + const args = calls[0]!.args; + expect(args[args.indexOf("--tools") + 1]).toBe(""); + expect(args[args.indexOf("--setting-sources") + 1]).toBe(""); + expect(args[args.indexOf("--setting-sources") + 2]).toBe("--max-turns"); + }); + + test("an npm cmd-shim Claude CLI install keeps launching through cmd.exe", async () => { + const shim = join(dir, "claude.cmd"); + writeFileSync(shim, NPM_CMD_SHIM); + const calls: Array<{ file: string; args: readonly string[] }> = []; + const adapter = createClaudeCliAdapter({ adapter: "claude-cli", baseUrl: CLAUDE_CLI_PROFILE.canonicalBaseUrl } as OcxProviderConfig, { + platform: "win32", + which: () => shim, + usageAdmission: async () => ({ state: "available", checkedAt: 0 }), + spawn: (file, args) => { calls.push({ file, args }); return resultChild(); }, + }); + const events: AdapterEvent[] = []; + await adapter.runTurn!({ ...parsed, modelId: "claude-sonnet-4-6" } as OcxParsedRequest, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + expect(events.find(event => event.type === "error" && (event as { code?: string }).code === "cli_dispatcher_drops_empty_args")).toBeUndefined(); + expect(calls).toHaveLength(1); + expect(calls[0]!.args.slice(0, 3)).toEqual(["/d", "/s", "/c"]); + }); +}); + + diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 8c92cc8ae48..fed4bb7ebc7 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -16,7 +16,7 @@ "responses-auto-pause-replay-owner.test.ts": "responses", "responses-account-auto-pause.test.ts": "responses", "oauth-account-auto-pause.test.ts": "oauth", "responses-account-auto-pause-wire-owner.test.ts": "responses", "oauth-upsert-paused-api-key.test.ts": "oauth", "management-account-auto-pause.test.ts": "server", "cli-account-key-pause.test.ts": "cli", "oauth-account-auto-pause-revision.test.ts": "oauth", "config-account-auto-pause-revisions.test.ts": "config", "codex-provider-routing.test.ts": "codex-integration", "claude-picker-trust-windows.test.ts": "claude-integration", "claude-picker-windows.test.ts": "claude-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", - "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", + "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", "coding-agent-windows-dispatch.test.ts": "adapters", "coding-agent-native-empty-argv.test.ts": "windows", "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", "messages-native-empty-inline.test.ts": "claude-integration", diff --git a/tests/providers/codebuddy-adapter.test.ts b/tests/providers/codebuddy-adapter.test.ts index aafde43c0a4..f5b7ee7f68c 100644 --- a/tests/providers/codebuddy-adapter.test.ts +++ b/tests/providers/codebuddy-adapter.test.ts @@ -193,6 +193,7 @@ describe("codebuddy runTurn fails closed before any spawn", () => { const adapter = createCodeBuddyAdapter(provider(), { platform: "win32", which: () => "C:\\npm\\codebuddy.cmd", + readDispatcher: () => '@ECHO off\r\n"%_prog%" "%dp0%\\node_modules\\@tencent-ai\\codebuddy-code\\bin\\codebuddy" %*\r\n', spawn: (seenCommand, seenArgs, seenOptions) => { command = seenCommand; args = seenArgs; @@ -730,6 +731,7 @@ describe("codebuddy runTurn streams a headless turn", () => { platform: "win32", spawn: () => child as unknown as ChildProcess, which: () => "C:\\npm\\codebuddy.cmd", + readDispatcher: () => '@ECHO off\r\n"%_prog%" "%dp0%\\node_modules\\@tencent-ai\\codebuddy-code\\bin\\codebuddy" %*\r\n', killWindowsProcessTree: pid => { killedTrees.push(pid); child.exitCode = 1; diff --git a/tests/windows/coding-agent-native-empty-argv.test.ts b/tests/windows/coding-agent-native-empty-argv.test.ts new file mode 100644 index 00000000000..d7c143cca7b --- /dev/null +++ b/tests/windows/coding-agent-native-empty-argv.test.ts @@ -0,0 +1,116 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { clearCodingAgentBinaryCache } from "../../src/adapters/coding-agent/profile"; +import { createQoderAdapter } from "../../src/adapters/qoder/adapter"; +import { commandInvocation } from "../../src/lib/win-exec"; +import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../src/types"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; + +// #7518 on a real Windows host: the official Qoder CN layout, a native receiver that records the +// argv it was actually given, and real PATH resolution and spawn. Linux quoting fixtures cannot +// show what CreateProcess, cmd.exe and PowerShell deliver, so this file only runs on win32. +const ISOLATION_ARGS = ["--tools", "", "--strict-mcp-config", "--setting-sources", "", "--max-turns", "1"]; + +let root = ""; +let entryDir = ""; +let binDir = ""; +let argvFile = ""; + +function containsRun(argv: readonly string[], run: readonly string[]): boolean { + return argv.some((_, start) => run.every((value, offset) => argv[start + offset] === value)); +} + +function receivedArgv(): string[] { + expect(existsSync(argvFile)).toBe(true); + return JSON.parse(readFileSync(argvFile, "utf8")) as string[]; +} + +function runDispatcher(dispatcher: string, args: readonly string[]): void { + const invocation = commandInvocation(dispatcher, args, "win32"); + const result = spawnSync(invocation.file, invocation.args, { ...invocation.options, env: process.env, windowsHide: true, encoding: "utf8" }); + expect({ status: result.status, stderr: result.stderr }).toMatchObject({ status: 0 }); +} + +describe.skipIf(process.platform !== "win32")("Windows coding-agent empty argv (native receiver)", () => { + beforeAll(async () => { + root = mkdtempSync(join(tmpdir(), "ocx-empty-argv-")); + entryDir = join(root, "entry"); + binDir = join(root, "bin", "qoderclicn"); + mkdirSync(entryDir, { recursive: true }); + mkdirSync(binDir, { recursive: true }); + argvFile = join(binDir, "argv.json"); + const source = join(root, "receiver.ts"); + writeFileSync(source, [ + 'import { writeFileSync } from "node:fs";', + 'import { dirname, join } from "node:path";', + 'writeFileSync(join(dirname(process.execPath), "argv.json"), JSON.stringify(process.argv.slice(2)));', + 'process.stdout.write(JSON.stringify({ type: "result", subtype: "success", is_error: false }) + "\\n");', + ].join("\n")); + const build = await Bun.build({ entrypoints: [source], compile: { target: "bun-windows-x64", outfile: join(binDir, "qoderclicn.exe") } }); + if (!build.success) throw new Error("receiver compile failed: " + build.logs.map(log => log.message).join("\n")); + // The vendor's dispatcher shape: cmd -> powershell -File .ps1 -- %* -> native CLI. + const powershell = join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe"); + writeFileSync(join(entryDir, "qodercn.cmd"), '@echo off\r\n"' + powershell + '" -NoProfile -ExecutionPolicy Bypass -File "%~dp0qodercn-dispatcher.ps1" -- %*\r\n'); + writeFileSync(join(entryDir, "qodercn-dispatcher.ps1"), '& "' + join(binDir, "qoderclicn.exe") + '" @args\r\nexit $LASTEXITCODE\r\n'); + // An npm cmd-shim shape: cmd forwards %* straight to the native image. + writeFileSync(join(entryDir, "shimcn.cmd"), '@ECHO off\r\n"' + join(binDir, "qoderclicn.exe") + '" %*\r\n'); + }); + + afterAll(() => { + try { rmSync(root, { recursive: true, force: true }); } catch { /* a just-exited image can hold the directory briefly */ } + }); + + beforeEach(() => { + clearCodingAgentBinaryCache(); + rmSync(argvFile, { force: true }); + }); + + const parsed = { modelId: "Qwen3.8-Flash", stream: true, options: {}, context: { messages: [{ role: "user", content: "hello", timestamp: 0 }] } } as unknown as OcxParsedRequest; + const qoderCn = { adapter: "qoder", baseUrl: "https://qoder.cn", apiKey: "cn-pat" } as OcxProviderConfig; + + async function runTurnWithPath(pathValue: string): Promise { + const previous = process.env.PATH; + process.env.PATH = pathValue; + try { + const events: AdapterEvent[] = []; + await createQoderAdapter(qoderCn).runTurn!(parsed, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + return events; + } finally { + process.env.PATH = previous; + } + } + + test("control: the vendor-shaped PowerShell dispatcher drops the empty values", () => { + runDispatcher(join(entryDir, "qodercn.cmd"), ISOLATION_ARGS); + const argv = receivedArgv(); + expect(argv).toContain("--max-turns"); + expect(argv).not.toContain(""); + }); + + test("control: a dispatcher that forwards %* directly keeps them through cmd.exe", () => { + runDispatcher(join(entryDir, "shimcn.cmd"), ISOLATION_ARGS); + const argv = receivedArgv(); + expect(containsRun(argv, ["--tools", ""])).toBe(true); + expect(containsRun(argv, ["--setting-sources", "", "--max-turns", "1"])).toBe(true); + }); + + test("a turn with the dispatcher first on PATH launches the native executable and delivers both empty values", async () => { + // Only the fixture directories: a Qoder CLI already installed on the host must not be found. + const events = await runTurnWithPath([entryDir, binDir].join(";")); + expect(events.filter(event => event.type === "error")).toEqual([]); + const argv = receivedArgv(); + expect(containsRun(argv, ["--tools", ""])).toBe(true); + expect(containsRun(argv, ["--setting-sources", "", "--max-turns", "1"])).toBe(true); + }); + + test("a dispatcher-only install is refused before anything is spawned", async () => { + const events = await runTurnWithPath(entryDir); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: "error", code: "cli_dispatcher_drops_empty_args" }); + expect(existsSync(argvFile)).toBe(false); + }); +}); + From c4d4bb2da2162e23c314befafc5ec104292902a0 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 23 Oct 2026 18:23:25 +0900 Subject: [PATCH 002/101] docs(providers): qualify Qoder native executable precedence across PATH folders (#7615) --- docs-site/src/content/docs/guides/providers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index dbba5e75c70..41caa0b660f 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -1324,7 +1324,7 @@ OpenCodex provides official adapter support for Qoder through the `qoder` (Globa - **Authentication:** Create a PAT in the account integrations page ([Global](https://qoder.com/account/integrations), [CN](https://qoder.cn/account/integrations)) and paste it as the provider's API key. The stored key reaches the CLI only as `QODER_PERSONAL_ACCESS_TOKEN` (Global) or `QODERCN_PERSONAL_ACCESS_TOKEN` (CN) in a scoped child environment. - **Region Isolation:** Each preset accepts only its canonical destination (`https://qoder.com` or `https://qoder.cn`) and resolves its own executable. Credentials, model cache, usage, and health are independent; neither region falls back to the other. An older custom provider named `qoder` with a different destination keeps its existing adapter and URL. -- **Windows executables:** Each preset launches its CLI's native executable (`qoder.exe` or `qodercli.exe` for Global, `qodercn.exe` or `qoderclicn.exe` for CN) when one is on PATH, even if an installer's `.cmd` dispatcher such as `qodercn.cmd` is found first. Qoder turns pass empty `--tools` and `--setting-sources` values to keep the CLI's built-in tools and local settings out, and a dispatcher that forwards through PowerShell drops those empty values. When only such a dispatcher is found, the turn fails before the CLI starts with `cli_dispatcher_drops_empty_args`; add the folder holding the native executable to PATH (the official CN installer places it in `%USERPROFILE%\.qoder-cn\bin\qoderclicn`) and retry. A dispatcher that passes arguments straight through, such as an npm shim, keeps working. +- **Windows executables:** Each preset launches its CLI's native executable (`qoder.exe` or `qodercli.exe` for Global, `qodercn.exe` or `qoderclicn.exe` for CN) when one is on PATH. Within one PATH folder the `.exe` wins over a same-name `.cmd`, and a later candidate name such as `qoderclicn.exe` anywhere on PATH wins over `qodercn.cmd`; an earlier PATH folder that holds only `qodercn.cmd` still shadows a later folder's `qodercn.exe`. Qoder turns pass empty `--tools` and `--setting-sources` values to keep the CLI's built-in tools and local settings out, and a dispatcher that forwards through PowerShell drops those empty values. When only such a dispatcher is found, the turn fails before the CLI starts with `cli_dispatcher_drops_empty_args`; add the folder holding the native executable to PATH (the official CN installer places it in `%USERPROFILE%\.qoder-cn\bin\qoderclicn`) and retry. A dispatcher that passes arguments straight through, such as an npm shim, keeps working. - **Model Discovery:** `qoder --list-models` is the authoritative entitlement roster for the current PAT. The cache is bound to an irreversible fingerprint of the token, so switching accounts never reuses another account's roster. If discovery fails, the provider degrades to a stale cache and then the documented static seed. - **Token usage:** Qoder CLI reports zero token counts unless its usage switch is on, so each preset turns it on in its scoped CLI child environment: `QODER_EXPOSE_TOKEN_USAGE=true` for Global and `QODERCN_EXPOSE_TOKEN_USAGE=true` for CN, which reads only its own prefix. - **Tool Ownership:** The CLI runs single-turn `stream-json` with `--tools ""`, `--strict-mcp-config`, setting sources disabled, and session persistence disabled, so Codex keeps exclusive tool ownership. Client-declared tools are exposed through an isolated request-scoped MCP server. From 306a88e08b387a7d3d2ee51bf04e72530285e5b4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:42:53 +0900 Subject: [PATCH 003/101] chore(release): open dev at 2.93.0 before releasing 2.92.0 (#7633) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/tauri.conf.json | 2 +- package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 3e0d89389d2..e76d2f47ac1 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2645,7 +2645,7 @@ dependencies = [ [[package]] name = "opencodex-desktop" -version = "2.92.0" +version = "2.93.0" dependencies = [ "base64 0.22.1", "dbus", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 525d32e9905..e721358a158 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "opencodex-desktop" -version = "2.92.0" +version = "2.93.0" description = "OpenCodex desktop shell" authors = ["OpenCodex contributors"] license = "MIT" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 0445c9ce40c..2d1844dbdaf 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenCodex", - "version": "2.92.0", + "version": "2.93.0", "identifier": "com.opencodex.desktop", "build": { "frontendDist": "../ui", diff --git a/package.json b/package.json index 89176da2851..217dc9c529a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.92.0", + "version": "2.93.0", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", From 93911e5bc0a27d6e2a4110844ea96e07bc926c37 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 24 Oct 2026 07:54:33 +0900 Subject: [PATCH 004/101] fix(deps): pin zod 4.6.6 and body-parser 2.4.0 for audit:high (#7642) GHSA-mrhx-c7xc-m3w5 and GHSA-27r3-jxmh-p828 blocked the 2.92.0 publish. Leave the package version at 2.93.0 on dev. Co-authored-by: Cursor Agent --- bun.lock | 11 +++++++---- package.json | 3 ++- tests/ci-workflows/install-scripts.test.ts | 2 +- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/bun.lock b/bun.lock index 1617d2a51cd..bcb2528f9b8 100644 --- a/bun.lock +++ b/bun.lock @@ -9,7 +9,7 @@ "@modelcontextprotocol/sdk": "^1.32.1", "@napi-rs/keyring": "1.3.0", "bun": "1.4.4", - "zod": "4.4.3", + "zod": "4.6.6", }, "devDependencies": { "@types/bun": "1.4.0", @@ -25,6 +25,7 @@ ], "overrides": { "@hono/node-server": "2.1.0", + "body-parser": "2.4.0", "fast-uri": "^3.1.7", "hono": "4.13.8", "ip-address": "^10.4.0", @@ -137,7 +138,7 @@ "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], - "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], + "body-parser": ["body-parser@2.4.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.1.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.3", "on-finished": "^2.4.1", "qs": "^6.16.1", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ=="], "bun": ["bun@1.4.4", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.4.4", "@oven/bun-darwin-x64": "1.4.4", "@oven/bun-freebsd-aarch64": "1.4.4", "@oven/bun-freebsd-x64": "1.4.4", "@oven/bun-linux-aarch64": "1.4.4", "@oven/bun-linux-aarch64-android": "1.4.4", "@oven/bun-linux-aarch64-musl": "1.4.4", "@oven/bun-linux-x64": "1.4.4", "@oven/bun-linux-x64-android": "1.4.4", "@oven/bun-linux-x64-musl": "1.4.4", "@oven/bun-windows-aarch64": "1.4.4", "@oven/bun-windows-x64": "1.4.4" }, "os": [ "!aix", "!sunos", "!openbsd", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-hj0ASdG6PNjLZGFAXktWb3Uaj/dcv/1wCsIttoEXADNIQBsGjTr+5uLKYbrDkuc7NT0Tkj4yPIqJI2jDxXdvVg=="], @@ -315,11 +316,13 @@ "wreq-js": ["wreq-js@2.3.1", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-vaKasaKeskrDKEuuO5Q5uamEG9a6FrF5ZSicH7TCvYS4RxF7/gzaU/vYqwJzcs+uydyJPVWY1KCvfVCgp0tiGA=="], - "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "zod": ["zod@4.6.6", "", {}, "sha512-4UmzQUF5fxvfcYHlbjBmejU+w+hbDnWFx4aS8kBx7nPBHcO2SqPbpAG8snbdg+Xse4pRefR98hgjFgIMCI/UhA=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], - "body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + + "body-parser/iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], } diff --git a/package.json b/package.json index 217dc9c529a..54e8387e2ab 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,7 @@ "@modelcontextprotocol/sdk": "^1.32.1", "@napi-rs/keyring": "1.3.0", "bun": "1.4.4", - "zod": "4.4.3" + "zod": "4.6.6" }, "devDependencies": { "@types/bun": "1.4.0", @@ -94,6 +94,7 @@ }, "overrides": { "@hono/node-server": "2.1.0", + "body-parser": "2.4.0", "fast-uri": "^3.1.7", "hono": "4.13.8", "ip-address": "^10.4.0", diff --git a/tests/ci-workflows/install-scripts.test.ts b/tests/ci-workflows/install-scripts.test.ts index 51e62b0b6fc..6d21c8726e6 100644 --- a/tests/ci-workflows/install-scripts.test.ts +++ b/tests/ci-workflows/install-scripts.test.ts @@ -98,7 +98,7 @@ describe("install scripts", () => { // proves the unified version; then delete testRunnerBun and the setup action's role. expect(pkg.dependencies?.bun).toBe("1.4.4"); expect(pkg.testRunnerBun).toBe("1.4.3"); - expect(pkg.dependencies?.zod).toBe("4.4.3"); + expect(pkg.dependencies?.zod).toBe("4.6.6"); expect(pkg.devDependencies?.typescript).toBe("7.0.2"); expect(pkg.devDependencies?.["@types/bun"]).toBe("1.4.0"); expect(pkg.scripts?.dev).toBe("bun run src/cli/index.ts start"); From 2494848f56e8a4f283722b2ebd816977e828c7af Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 24 Oct 2026 09:01:49 +0900 Subject: [PATCH 005/101] fix(deps): bump gui happy-dom to 21.0.4 for GHSA-834v-8h66-3h79 (#7644) Co-authored-by: lidge-jun <243035832+lidge-jun@users.noreply.github.com> --- gui/bun.lock | 4 ++-- gui/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/gui/bun.lock b/gui/bun.lock index 656b3ac165f..c8a6d4a42d4 100644 --- a/gui/bun.lock +++ b/gui/bun.lock @@ -15,7 +15,7 @@ "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", "@vitejs/plugin-react": "^6.0.5", - "happy-dom": "20.11.2", + "happy-dom": "21.0.4", "oxlint": "1.78.0", "react-refresh": "^0.18.0", "typescript": "~6.0.2", @@ -133,7 +133,7 @@ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - "happy-dom": ["happy-dom@20.11.2", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-7MB+bJLkxu3SowAfBJbjW+c55kNz5tkR45gu2qzrxznezhLeN5YIlJbwUgSzlGc+qWoZ8Ykg71H5ezz69xixrw=="], + "happy-dom": ["happy-dom@21.0.4", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-tFm+GaLBgo6o0awHnvHaNoraVo27qJ+2tZEtZ+cIa08hXbeitrxAJajLI05hsUy/2/YTKcUjQYzHe5mCcraLBw=="], "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], diff --git a/gui/package.json b/gui/package.json index b37de5dd949..045ed340086 100644 --- a/gui/package.json +++ b/gui/package.json @@ -27,7 +27,7 @@ "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", "@vitejs/plugin-react": "^6.0.5", - "happy-dom": "20.11.2", + "happy-dom": "21.0.4", "oxlint": "1.78.0", "react-refresh": "^0.18.0", "typescript": "~6.0.2", From ceef4889e4666dc0492a375d272eafa5b785361e Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 24 Oct 2026 10:57:44 +0900 Subject: [PATCH 006/101] test: settle Windows ACL work and owner locks before fixture removal (#7528) Settle async Windows ACL work and native-main owner locks before fixture removal, so hosted-Windows teardowns no longer fail with EPERM after passing assertions. No retry, sleep or timeout changes. Refs #7515 --- src/codex/native-main-owner.ts | 28 ++++++- structure/ops/test-sandbox-cleanup.md | 15 ++-- .../fixture-teardown-helper.test.ts | 73 +++++++++++++++++++ .../codex-auth-context.test.ts | 47 +++++++----- ...account-hard-lock-fallback-routing.test.ts | 16 ++-- tests/helpers/fixture-teardown.ts | 14 +++- tests/server/server-management-auth.test.ts | 6 ++ 7 files changed, 168 insertions(+), 31 deletions(-) diff --git a/src/codex/native-main-owner.ts b/src/codex/native-main-owner.ts index df2d7b7698b..a15b6b7fdc4 100644 --- a/src/codex/native-main-owner.ts +++ b/src/codex/native-main-owner.ts @@ -1,4 +1,4 @@ -import { join } from "node:path"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; import { Database } from "bun:sqlite"; @@ -299,6 +299,32 @@ export function nativeMainOwnerSnapshot(context: NativeProfileContext): NativeMa return entry ? { ...entry.snapshot } : null; } +/** + * Homes at or below `root` whose owner entry may still hold or reopen handles there. + * + * Conservative on purpose: besides a published database or side descriptor, an + * acquisition in flight (`drive`) holds its descriptor and database in locals until + * hardening returns, a pending retry timer will reopen them, and a detached entry is + * still closing. Removal code asks this before deleting a home. Windows refuses to + * delete a file SQLite holds open, so a removal that starts early fails with EBUSY + * only after its whole retry budget, without naming the owner. + */ +export function nativeMainOwnerHomesOpenAtOrBelow(root: string): string[] { + const fold = (path: string) => (process.platform === "win32" ? resolve(path).toLowerCase() : resolve(path)); + const base = fold(root); + const open: string[] = []; + const candidates = [ + ...[...entries.values()].filter(entry => entry.database || entry.file || entry.drive || entry.timer), + ...detachedEntries, + ]; + for (const entry of candidates) { + const suffix = relative(base, fold(entry.context.codexHome)); + if (suffix === ".." || suffix.startsWith(`..${sep}`) || isAbsolute(suffix)) continue; + open.push(entry.context.codexHome); + } + return open; +} + export function assertNativeMainOwner(context: NativeProfileContext): void { const entry = entries.get(context.homeId); if (entry?.snapshot.status === "held" && entry.database && !entry.closing) return; diff --git a/structure/ops/test-sandbox-cleanup.md b/structure/ops/test-sandbox-cleanup.md index 0f52b0837c4..9c68d01cf4e 100644 --- a/structure/ops/test-sandbox-cleanup.md +++ b/structure/ops/test-sandbox-cleanup.md @@ -77,11 +77,16 @@ fixture-specific cleanup or claim OS ACL coverage for synthetic tests. case roots (`tests/codex-integration/codex-quota-auto-refresh.test.ts`), and Anthropic instance root removal (`tests/helpers/anthropic-instance-fixture.ts`). It settles producers, then each root's config flights, closes the request-history SQLite index, -drains each root's ACL work, and only then removes drained roots. A producer or history failure -blocks every removal; a root-specific drain failure preserves that root while other drained roots -can be removed. Removal failures do not stop later roots. Environment restoration runs in finally, -and the first failure is rethrown. Undrained roots remain for run-level sandbox cleanup. -`tests/ci-workflows/fixture-teardown-helper.test.ts` verifies ordering and failure gating with injected owners. +drains each root's ACL work, then asks `nativeMainOwnerHomesOpenAtOrBelow` whether a native-main +owner below the root is still open, acquiring (descriptor and SQLite handle held while its hardener +runs), waiting on a retry, or closing; only roots that pass both are removed. An open owner fails +that root at once with an error naming the owner home, without spending the removal retry budget. +A producer or history failure blocks every removal; a root-specific drain or owner failure +preserves that root while other ready roots can be removed. Removal failures do not stop later +roots. Environment restoration runs in finally, and the first failure is rethrown. Undrained roots +remain for run-level sandbox cleanup. +`tests/ci-workflows/fixture-teardown-helper.test.ts` verifies ordering and failure gating with injected +owners, and with a real owner whose hardener is paused mid-acquisition. `tests/helpers/spawn-test-child.ts` owns behavior and warm-up children on the parent's live event loop. It captures output continuously, kills at the child deadline, and bounds exit/pipe diff --git a/tests/ci-workflows/fixture-teardown-helper.test.ts b/tests/ci-workflows/fixture-teardown-helper.test.ts index bdd936dca17..11bc42443ba 100644 --- a/tests/ci-workflows/fixture-teardown-helper.test.ts +++ b/tests/ci-workflows/fixture-teardown-helper.test.ts @@ -1,4 +1,8 @@ import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { retainNativeMainOwner } from "../../src/codex/native-main-owner"; import { flushConfigDirHardening } from "../../src/config/paths"; import { defaultFixtureTeardownDeps, @@ -6,6 +10,7 @@ import { type FixtureTeardownDeps, type FixtureTeardownPlan, } from "../helpers/fixture-teardown"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; function harness(overrides: Partial = {}) { const calls: string[] = []; @@ -14,6 +19,7 @@ function harness(overrides: Partial = {}) { settleConfigFlights: async root => { calls.push(`flight:${root}`); }, closeHistory: () => { calls.push("history"); }, drainAcl: async root => { calls.push(`acl:${root}`); }, + openLocks: () => [], remove: root => { calls.push(`remove:${root}`); }, ...overrides, }; @@ -127,4 +133,71 @@ describe("fixture removal ownership", () => { h.plan.restoreEnvironment = () => { throw last; }; await expect(drainAndRemoveFixtureRoots(h.plan, h.deps)).rejects.toBe(first); }); + + test("an owner still open under a root keeps only that root and names it", async () => { + const h = harness({ openLocks: root => (root === "A" ? ["A/codex"] : []) }); + await expect(drainAndRemoveFixtureRoots(h.plan, h.deps)).rejects.toThrow("native-main owner still open for A/codex"); + expect(h.calls).not.toContain("remove:A"); + expect(h.calls).toContain("remove:B"); + expect(h.calls.filter(call => call === "restore")).toHaveLength(1); + }); + + test("the default lock probe sees a held owner below the root until its release closes", async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-teardown-lock-")); + const codexHome = join(root, "codex"); + const sibling = `${root}-sibling`; + mkdirSync(codexHome); + const owner = retainNativeMainOwner( + { codexHome, homeId: `teardown-lock-${process.pid}` } as never, + { hardenPath: async () => {} }, + ); + try { + await new Promise(held => owner.subscribe(snapshot => { if (snapshot.status === "held") held(); })); + expect(defaultFixtureTeardownDeps.openLocks(root)).toEqual([codexHome]); + expect(defaultFixtureTeardownDeps.openLocks(codexHome)).toEqual([codexHome]); + expect(defaultFixtureTeardownDeps.openLocks(sibling)).toEqual([]); + await owner.release(); + expect(defaultFixtureTeardownDeps.openLocks(root)).toEqual([]); + } finally { + await owner.release(); + removeTreeWithRetry(root); + } + }); + + test("an owner paused mid-acquisition keeps its root until preparation and release finish", async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-teardown-acquire-")); + const codexHome = join(root, "codex"); + mkdirSync(codexHome); + let entered!: () => void, resume!: () => void; + const hardening = new Promise(done => { entered = done; }); + const resumed = new Promise(done => { resume = done; }); + const owner = retainNativeMainOwner( + { codexHome, homeId: `teardown-acquire-${process.pid}` } as never, + { hardenPath: async () => { entered(); await resumed; } }, + ); + const removed: string[] = []; + const plan = (): FixtureTeardownPlan => ({ roots: [{ path: root, remove: path => { removed.push(path); removeTreeWithRetry(path); } }], restoreEnvironment: () => {} }); + try { + // The descriptor and SQLite handle exist only in locals while the hardener runs. + await hardening; + expect(owner.snapshot().status).toBe("acquiring"); + await expect(drainAndRemoveFixtureRoots(plan())).rejects.toThrow(`native-main owner still open for ${codexHome}`); + expect(removed).toEqual([]); + + const released = owner.release(); + // Still closing: the release waits for the paused acquisition. + await expect(drainAndRemoveFixtureRoots(plan())).rejects.toThrow("native-main owner still open"); + expect(removed).toEqual([]); + + resume(); + await released; + expect(defaultFixtureTeardownDeps.openLocks(root)).toEqual([]); + await drainAndRemoveFixtureRoots(plan()); + expect(removed).toEqual([root]); + } finally { + resume(); + await owner.release(); + if (removed.length === 0) removeTreeWithRetry(root); + } + }); }); diff --git a/tests/codex-integration/codex-auth-context.test.ts b/tests/codex-integration/codex-auth-context.test.ts index cdd00a433db..b26014ff727 100644 --- a/tests/codex-integration/codex-auth-context.test.ts +++ b/tests/codex-integration/codex-auth-context.test.ts @@ -69,7 +69,7 @@ import { resetCodexRoutingForManualSelection, } from "../../src/codex/routing"; import type { OcxConfig, OcxProviderConfig } from "../../src/types"; -import { setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; +import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; import { blockNativeMainStartupForUnownedServiceHome, completeNativeMainRecovery, @@ -86,17 +86,19 @@ import { recordContextSessionOwner, clearContextSessionOwnersForTests } from ".. import { handleContextHistory } from "../../src/server/context-history"; import { resetContextRelayActivationForTests } from "../../src/codex/context-compat"; import { hasForwardableCodexBearer } from "../../src/server/auth-cors"; -import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { drainAndRemoveFixtureRoots } from "../helpers/fixture-teardown"; let testDir: string; let previousOpencodexHome: string | undefined; let previousCodexHome: string | undefined; beforeEach(() => { - // This suite validates refresh admission and auth-context outcomes. Real icacls - // processes are covered elsewhere and can retain temp-dir handles long enough - // to obscure those assertions under Windows isolated-test load. + // This suite validates refresh admission and auth-context outcomes; real icacls is + // covered by tests/windows. Both runners are stubbed: the native-main claim lock is + // hardened through the ASYNC runner, and with only the sync one stubbed, real icacls + // still rewrote that lock's DACL under the home this teardown removes (#7515). setIcaclsRunnerForTests(() => ({ success: true, exitCode: 0, timedOut: false, stdout: "" })); + setAsyncIcaclsRunnerForTests(async () => ({ success: true, exitCode: 0, timedOut: false, stdout: "" })); testDir = mkdtempSync(join(tmpdir(), "ocx-auth-ctx-")); previousOpencodexHome = process.env.OPENCODEX_HOME; process.env.OPENCODEX_HOME = testDir; @@ -113,21 +115,28 @@ beforeEach(() => { clearAccountNeedsReauth("pool-b"); }); -afterEach(() => { +afterEach(async () => { resetContextRelayActivationForTests(); - setIcaclsRunnerForTests(null); - removeTreeWithRetry(testDir); - clearThreadAccountMap(); - clearCodexUpstreamHealth(); - clearAccountQuota(); - setMainAccountPlan(null); - __resetGuardianState(); - clearAccountNeedsReauth("pool-a"); - clearAccountNeedsReauth("pool-b"); - if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; - else process.env.OPENCODEX_HOME = previousOpencodexHome; - if (previousCodexHome === undefined) delete process.env.CODEX_HOME; - else process.env.CODEX_HOME = previousCodexHome; + // Settle producers, config flights and ACL work, and refuse a home whose native-main + // owner is still open, before removal; the stubs stay installed until removal ends. + await drainAndRemoveFixtureRoots({ + roots: [{ path: testDir }], + restoreEnvironment: () => { + setIcaclsRunnerForTests(null); + setAsyncIcaclsRunnerForTests(null); + clearThreadAccountMap(); + clearCodexUpstreamHealth(); + clearAccountQuota(); + setMainAccountPlan(null); + __resetGuardianState(); + clearAccountNeedsReauth("pool-a"); + clearAccountNeedsReauth("pool-b"); + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + }, + }); }); function config(): OcxConfig { diff --git a/tests/codex-integration/main-account-hard-lock-fallback-routing.test.ts b/tests/codex-integration/main-account-hard-lock-fallback-routing.test.ts index 5db587eed26..07a1191cb6c 100644 --- a/tests/codex-integration/main-account-hard-lock-fallback-routing.test.ts +++ b/tests/codex-integration/main-account-hard-lock-fallback-routing.test.ts @@ -18,7 +18,7 @@ import { captureConfigGeneration } from "../../src/lib/state-store-sweeper"; import type { RequestLogContext } from "../../src/server/request-log"; import type { OcxConfig } from "../../src/types"; import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; -import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { drainAndRemoveFixtureRoots } from "../helpers/fixture-teardown"; const now = Date.UTC(2026, 8, 5); const FALLBACK = { model: "deepseek/deepseek-chat", reasoningEffort: "low" }; @@ -39,7 +39,7 @@ beforeEach(() => { observeMainQuotaIdentity("fallback-account-a"); }); -afterEach(() => { +afterEach(async () => { releaseSpendHome?.(); releaseSpendHome = undefined; globalThis.fetch = originalFetch; @@ -47,9 +47,15 @@ afterEach(() => { clearMainAccountInfoCache(); deleteAllScopedHealth("pool-a"); clearComboRecallForTests(); - if (previousHome === undefined) delete process.env.OPENCODEX_HOME; - else process.env.OPENCODEX_HOME = previousHome; - removeTreeWithRetry(home); + // Saving a pool account starts the config-dir ACL harden without awaiting it; the + // shared teardown settles that flight and its icacls child before removing the home. + await drainAndRemoveFixtureRoots({ + roots: [{ path: home }], + restoreEnvironment: () => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + }, + }); }); function observe(quota: Omit): void { diff --git a/tests/helpers/fixture-teardown.ts b/tests/helpers/fixture-teardown.ts index e64fde2fb87..86648bff577 100644 --- a/tests/helpers/fixture-teardown.ts +++ b/tests/helpers/fixture-teardown.ts @@ -1,4 +1,5 @@ import { flushNativeMainStartupReleases } from "../../src/codex/native-profile-startup"; +import { nativeMainOwnerHomesOpenAtOrBelow } from "../../src/codex/native-main-owner"; import { flushConfigDirHardening } from "../../src/config/paths"; import { closeRequestHistoryIndex } from "../../src/routing/history/indexer"; import { flushWindowsSecretAclReapsBeforeRemoval } from "../../src/lib/windows-secret-acl"; @@ -11,6 +12,7 @@ export interface FixtureTeardownDeps { settleConfigFlights(root: string): Promise; closeHistory(): void; drainAcl(root: string): Promise; + openLocks(root: string): string[]; remove(path: string): void; } export const defaultFixtureTeardownDeps: Readonly = { @@ -18,9 +20,18 @@ export const defaultFixtureTeardownDeps: Readonly = { settleConfigFlights: flushConfigDirHardening, closeHistory: closeRequestHistoryIndex, drainAcl: flushWindowsSecretAclReapsBeforeRemoval, + openLocks: nativeMainOwnerHomesOpenAtOrBelow, remove: removeTreeWithRetry, }; +/** A lock this process still holds keeps its root: the owner is named now, not after a retry budget. */ +function assertNoOpenLocks(root: string, openLocks: (root: string) => string[]): void { + const open = openLocks(root); + if (open.length === 0) return; + throw new Error(`[test] not removing ${root}: native-main owner still open for ${open.join(", ")}; ` + + "stop the server or release the owner before teardown"); +} + /** Remove only roots whose owners settled; restoration cannot mask the first failure. */ export async function drainAndRemoveFixtureRoots( plan: FixtureTeardownPlan, @@ -39,7 +50,8 @@ export async function drainAndRemoveFixtureRoots( const historyClosed = await attempt(() => deps.closeHistory()); for (const [index, root] of plan.roots.entries()) { const drained = await attempt(() => deps.drainAcl(root.path)); - ready[index] = ready[index] && drained; + const unlocked = drained && await attempt(() => assertNoOpenLocks(root.path, deps.openLocks)); + ready[index] = ready[index] && unlocked; } if (producersSettled && historyClosed) { for (const [index, root] of plan.roots.entries()) { diff --git a/tests/server/server-management-auth.test.ts b/tests/server/server-management-auth.test.ts index cf0c80f58a4..6469416ed34 100644 --- a/tests/server/server-management-auth.test.ts +++ b/tests/server/server-management-auth.test.ts @@ -28,6 +28,7 @@ import { hardenSecretPath, hardenedSecretPathCountForTests, resetHardenedStateForTests, + setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests, setPlatformForTests, timedOutSecretPathCountForTests, @@ -117,6 +118,10 @@ beforeEach(() => { resetContextRelayActivationForTests(); process.env.OPENCODEX_API_AUTH_TOKEN = "data-secret"; process.env.OPENCODEX_ADMIN_AUTH_TOKEN = "admin-secret"; + // These cases assert credential separation, not ACLs (tests/windows owns those). Real + // async icacls on the native-main owner lock under testHome left it with an empty DACL + // that the teardown could not delete (#7515). Sync-runner cases still inject their own. + setAsyncIcaclsRunnerForTests(async () => ({ success: true, exitCode: 0, timedOut: false, stdout: "" })); }); afterEach(async () => { @@ -130,6 +135,7 @@ afterEach(async () => { else process.env.CODEX_HOME = previousCodexHome; setSystemRestartIoForTests(); setIcaclsRunnerForTests(null); + setAsyncIcaclsRunnerForTests(null); setPlatformForTests(null); resetHardenedStateForTests(); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; From 43f872f42f31fc0b2bdadf3e47fc4ed3414982ed Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 24 Oct 2026 14:07:19 +0900 Subject: [PATCH 007/101] test: make codex-runtime, client-connect and loopback-listener tests deterministic (#7552) Make the codex-runtime, client-connect and loopback-listener tests deterministic: bounded child deadlines with process milestones, and a port-0 public listener in place of the free-port-then-bind race. No sleeps or timeout increases. Refs #7403 #7404 Co-authored-by: Shreyas652 <66720396+Shreyas652@users.noreply.github.com> --- tests/clients/client-connect.test.ts | 154 ++++++++++++------ tests/codex-integration/codex-runtime.test.ts | 18 +- .../loopback-listener-integration.test.ts | 62 ++++--- 3 files changed, 165 insertions(+), 69 deletions(-) diff --git a/tests/clients/client-connect.test.ts b/tests/clients/client-connect.test.ts index c99a04342ed..f3472bc4b71 100644 --- a/tests/clients/client-connect.test.ts +++ b/tests/clients/client-connect.test.ts @@ -60,9 +60,23 @@ const CLIENT_LIFECYCLE_FIXTURE_IMPORT_PROLOGUE = const CLIENT_FIXTURE_FAILURE_CATEGORIES = ["module_load", "config_setup", "desktop_setup", "scenario", "child_failed"] as const; type ClientFixtureFailureCategory = typeof CLIENT_FIXTURE_FAILURE_CATEGORIES[number]; -const TRANSACTION_PHASES = ["module_load", "module_ready", "connect_entered", "connect_resolved", "connect_rejected", "state_read", "result_published"] as const; +const TRANSACTION_PHASES = [ + "module_load", "module_ready", "connect_entered", "connect_resolved", "connect_rejected", + "operation_entered", "operation_settled", "state_read", "result_published", +] as const; type TransactionProgress = { phase: typeof TRANSACTION_PHASES[number]; elapsedMs: number }; +/** + * Child-side half of transactionProgress: each --eval child writes its milestones to stderr + * synchronously, so a deadline SIGKILL cannot drop the last one it reached. + */ +const CHILD_MILESTONE_PROLOGUE = ` + const transactionStarted = Date.now(); + const markTransaction = phase => require("node:fs").writeSync(2, JSON.stringify({ + clientTransactionPhase: phase, elapsedMs: Math.max(0, Date.now() - transactionStarted), + }) + "\\n"); +`; + /** Only this fixed diagnostic envelope may reach a parent-side failure message. */ function transactionProgress(stderr: unknown): TransactionProgress | undefined { if (typeof stderr !== "string") return undefined; @@ -82,6 +96,17 @@ function transactionProgress(stderr: unknown): TransactionProgress | undefined { return undefined; } +/** The last lifecycle-fixture phase the child reached, so a deadline kill still names where it stopped. */ +function lastFixturePhase(stderr: unknown): ClientFixtureFailureCategory | undefined { + if (typeof stderr !== "string") return undefined; + for (const line of stderr.slice(-8192).split("\n").reverse()) { + const reached = /^\{"clientFixturePhase":"([a-z_]+)"\}$/.exec(line.trim())?.[1]; + const phase = CLIENT_FIXTURE_FAILURE_CATEGORIES.find(value => value === reached); + if (phase) return phase; + } + return undefined; +} + class ClientStateProbeError extends Error { constructor( readonly pid: number, @@ -97,6 +122,32 @@ class ClientStateProbeError extends Error { } } +/** + * Run one repository --eval child under a finite deadline and parse its last stdout line. + * + * spawnSync blocks this worker, so Bun's per-test timeout cannot end a wedged child: a call + * without `timeout` turns one stalled child into a file that never finishes (#7404). + * Every synchronous child in this file goes through here; the only child text that may reach a + * failure message is the allowlisted milestone read by transactionProgress. + */ +function runEvalChild(script: string, env: NodeJS.ProcessEnv, timeoutMs = INTERNAL_DEADLINE_MS) { + const result = spawnSync(process.execPath, ["--eval", script], { + cwd: repoRoot, + env, + encoding: "utf8", + timeout: timeoutMs, + killSignal: "SIGKILL", + }); + const progress = transactionProgress(result.stderr); + if (result.error || result.status !== 0 || result.signal !== null) { + throw new ClientStateProbeError(result.pid, result.status, result.signal, (result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT", undefined, progress); + } + let parsed: Record; + try { parsed = JSON.parse(result.stdout.trim().split("\n").at(-1) ?? "{}"); } + catch { throw new ClientStateProbeError(result.pid, result.status, result.signal, false, undefined, progress); } + return { status: result.status, stderr: result.stderr, parsed }; +} + async function readStateProbe(script: string, home: string, timeoutMs = INTERNAL_DEADLINE_MS) { const maxCaptureBytes = 1024 * 1024; const cleanupMs = 1_000; @@ -421,10 +472,7 @@ function runTransactionScenario( } const script = options.script ?? ` const { existsSync, readFileSync, writeSync } = require("node:fs"); - const transactionStarted = Date.now(); - const markTransaction = phase => writeSync(2, JSON.stringify({ - clientTransactionPhase: phase, elapsedMs: Math.max(0, Date.now() - transactionStarted), - }) + "\\n"); + ${CHILD_MILESTONE_PROLOGUE} markTransaction("module_load"); const { createHash } = require("node:crypto"); ${CLIENT_TRANSACTION_EVAL_IMPORT_PROLOGUE} @@ -522,21 +570,10 @@ function runTransactionScenario( if (failures.length) throw new AggregateError(failures, "Could not clean client transaction homes"); }; try { - const result = spawnSync(process.execPath, ["--eval", script], { - cwd: repoRoot, - env: { ...process.env, OPENCODEX_HOME: opencodexHome, CODEX_HOME: codexHome, OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: join(opencodexHome, "desktop") }, - encoding: "utf8", - timeout: options.timeoutMs ?? INTERNAL_DEADLINE_MS, - killSignal: "SIGKILL", - }); - const progress = transactionProgress(result.stderr); - if (result.error || result.status !== 0 || result.signal !== null) { - throw new ClientStateProbeError(result.pid, result.status, result.signal, (result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT", undefined, progress); - } - let parsed: Record; - try { parsed = JSON.parse(result.stdout.trim().split("\n").at(-1) ?? "{}"); } - catch { throw new ClientStateProbeError(result.pid, result.status, result.signal, false, undefined, progress); } - return { status: result.status, stderr: result.stderr, parsed, configBytes: readFileSync(configPath, "utf8"), cleanup }; + const result = runEvalChild(script, { + ...process.env, OPENCODEX_HOME: opencodexHome, CODEX_HOME: codexHome, OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: join(opencodexHome, "desktop"), + }, options.timeoutMs); + return { ...result, configBytes: readFileSync(configPath, "utf8"), cleanup }; } catch (error) { try { cleanup(); } catch (cleanupError) { throw new AggregateError([error, cleanupError], "Client transaction failed and fixture cleanup failed"); } @@ -577,7 +614,8 @@ describe("connect transaction and offline disconnect", () => { test("transaction fixture stops a child retained after valid output", async () => { const proofHome = mkdtempSync(join(tmpdir(), "ocx-transaction-child-proof-")); const markerPath = join(proofHome, "child-started.json"); - const naturalExitPath = join(proofHome, "natural-exit"); + // The child never exits on its own, so only the deadline kill can end it. A natural-exit + // timer here would race that kill on wall-clock time under load. const script = ` const fs = require("node:fs"); fs.writeFileSync(${JSON.stringify(markerPath)}, JSON.stringify({ @@ -586,7 +624,7 @@ describe("connect transaction and offline disconnect", () => { fs.writeSync(1, '{"ok":true}\\n'); fs.writeSync(2, '{"clientTransactionPhase":"result_published","elapsedMs":1}\\n'); fs.writeSync(2, '{"clientTransactionPhase":"private-child-secret","elapsedMs":2}\\n'); - setTimeout(() => { fs.writeFileSync(${JSON.stringify(naturalExitPath)}, "exited"); }, 5_000); + setInterval(() => {}, 1_000); `; let run: Awaited> | undefined; try { @@ -600,7 +638,6 @@ describe("connect transaction and offline disconnect", () => { expect(failure.timedOut).toBe(true); expect(failure.progress).toEqual({ phase: "result_published", elapsedMs: 1 }); expect(failure.message).not.toContain("private-child-secret"); - expect(existsSync(naturalExitPath)).toBe(false); const proof = JSON.parse(readFileSync(markerPath, "utf8")) as { pid: number; home: string; codexHome: string }; expect(proof.pid).toBe(failure.pid); expect(existsSync(proof.home)).toBe(false); @@ -806,19 +843,25 @@ function runConnectedStateScenario(mode: "sync-401" | "sync-503" | "disconnect-c const script = ` const fs = require("node:fs"); const path = require("node:path"); + ${CHILD_MILESTONE_PROLOGUE} + markTransaction("module_load"); const { disconnectClient, syncConnectedClient } = require("./src/client/connect"); const { readClientConnectionState } = require("./src/client/state"); const mode = ${JSON.stringify(mode)}; + markTransaction("module_ready"); (async () => { let result = null; let error = null; try { + markTransaction("operation_entered"); if (mode === "disconnect-conflict" || mode === "disconnect-process-journal") result = await disconnectClient({}, { lifecycleLockDeps: { lockPath: process.env.OPENCODEX_HOME + "/lifecycle.sqlite" } }); else result = await syncConnectedClient({}, { lifecycleLockDeps: { lockPath: process.env.OPENCODEX_HOME + "/lifecycle.sqlite" }, fetchImpl: async () => Response.json({ error: "fixture" }, { status: mode === "sync-401" ? 401 : 503 }), }); } catch (cause) { error = cause instanceof Error ? cause.message : String(cause); } + markTransaction("operation_settled"); + markTransaction("state_read"); console.log(JSON.stringify({ result, error, @@ -826,22 +869,27 @@ function runConnectedStateScenario(mode: "sync-401" | "sync-503" | "disconnect-c tokenExists: fs.existsSync(path.join(process.env.OPENCODEX_HOME, "service-api-token")), journalExists: fs.existsSync(path.join(process.env.CODEX_HOME, "opencodex-journal.json")), })); + markTransaction("result_published"); })(); `; - const child = spawnSync(process.execPath, ["--eval", script], { - cwd: repoRoot, - env: { ...process.env, OPENCODEX_HOME: opencodexHome, CODEX_HOME: codexHome, OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: join(opencodexHome, "desktop") }, - encoding: "utf8", - }); - const parsed = JSON.parse(child.stdout.trim().split("\n").at(-1) ?? "{}") as Record; - return { - status: child.status, - parsed, - cleanup: () => { - removeTreeWithRetry(opencodexHome); - removeTreeWithRetry(codexHome); - }, + const cleanup = () => { + const failures: unknown[] = []; + for (const home of [opencodexHome, codexHome]) { + try { removeTreeWithRetry(home); } + catch (error) { failures.push(error); } + } + if (failures.length) throw new AggregateError(failures, "Could not clean connected client homes"); }; + try { + const { status, parsed } = runEvalChild(script, { + ...process.env, OPENCODEX_HOME: opencodexHome, CODEX_HOME: codexHome, OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: join(opencodexHome, "desktop"), + }); + return { status, parsed, cleanup }; + } catch (error) { + try { cleanup(); } + catch (cleanupError) { throw new AggregateError([error, cleanupError], "Connected client scenario failed and fixture cleanup failed"); } + throw error; + } } describe("connected sync and disconnect conflicts", () => { @@ -923,8 +971,11 @@ describe("recoverable connected key rotation", () => { const script = ` const fs = require("node:fs"); const path = require("node:path"); + ${CHILD_MILESTONE_PROLOGUE} + markTransaction("module_load"); const { rotateConnectedClientKey } = require("./src/client/connect"); const { readClientConnectionState } = require("./src/client/state"); + markTransaction("module_ready"); let commitCalls = 0; let committed = false; const oldKey = ${JSON.stringify(oldKey)}; @@ -953,7 +1004,10 @@ describe("recoverable connected key rotation", () => { }; (async () => { const credential = new TextEncoder().encode("ocx_admin_rotation_test"); + markTransaction("operation_entered"); const result = await rotateConnectedClientKey({ credential: { kind: "admin", value: credential } }, { fetchImpl, lifecycleLockDeps: { lockPath: process.env.OPENCODEX_HOME + "/lifecycle.sqlite" } }); + markTransaction("operation_settled"); + markTransaction("state_read"); console.log(JSON.stringify({ result, state: readClientConnectionState(), @@ -962,16 +1016,13 @@ describe("recoverable connected key rotation", () => { commitCalls, credentialZeroed: credential.every(value => value === 0), })); + markTransaction("result_published"); })(); `; - const child = spawnSync(process.execPath, ["--eval", script], { - cwd: repoRoot, - env: { ...process.env, OPENCODEX_HOME: opencodexHome, OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: join(opencodexHome, "desktop") }, - encoding: "utf8", - }); try { - expect(child.status).toBe(0); - const result = JSON.parse(child.stdout.trim().split("\n").at(-1) ?? "{}") as Record; + const { parsed: result } = runEvalChild(script, { + ...process.env, OPENCODEX_HOME: opencodexHome, OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: join(opencodexHome, "desktop"), + }); expect(result.commitCalls).toBe(2); expect(result.tokenIsNew).toBe(true); expect(result.backup).toBe(false); @@ -1055,7 +1106,7 @@ function runDesktopLifecycleScenario(mode: string) { catalogFingerprint: crypto.createHash("sha256").update(catalog).digest("base64url"), priorCatalog: Buffer.from(prior).toString("base64"), } }; - fixtureFailurePhase = "config_setup"; + markFixturePhase("config_setup"); configApi.saveConfig(config); if (configApi.readConfigDiagnostics().source !== "file" || stateApi.readClientConnectionState().kind !== "connected") { throw new Error("fixture_config_invalid"); @@ -1067,7 +1118,7 @@ function runDesktopLifecycleScenario(mode: string) { const profilePath = path.join(desktop, "fixture.json"); const baselinePath = path.join(home, "desktop-remote", "baseline.json"); const unused = mode.startsWith("status-") || mode === "disconnect-expected-owner"; - fixtureFailurePhase = "desktop_setup"; + markFixturePhase("desktop_setup"); if (!unused) { fs.writeFileSync(path.join(desktop, "_meta.json"), JSON.stringify({ appliedId: "fixture", entries: [{ id: "fixture", name: "opencodex" }], foreignMeta: "preserve" })); fs.writeFileSync(profilePath, JSON.stringify({ @@ -1135,7 +1186,7 @@ function runDesktopLifecycleScenario(mode: string) { } throw new Error("unexpected fixture request"); }; - fixtureFailurePhase = "scenario"; + markFixturePhase("scenario"); await (async () => { let result = null, error = null, second = null, statusInside = null, statusOutside = null, cliRotation = null; const credential = new TextEncoder().encode("ocx_admin_fixture"); @@ -1254,7 +1305,13 @@ function runDesktopLifecycleScenario(mode: string) { const fixturePath = join(root, "client-lifecycle-fixture.test.ts"); writeFileSync(fixturePath, `import { test } from "bun:test"; test("isolated client lifecycle scenario", async () => { - let fixtureFailurePhase = "module_load"; + // Written synchronously to stderr so a deadline SIGKILL still leaves the phase it stopped in. + let fixtureFailurePhase; + const markFixturePhase = phase => { + fixtureFailurePhase = phase; + require("node:fs").writeSync(2, JSON.stringify({ clientFixturePhase: phase }) + "\\n"); + }; + markFixturePhase("module_load"); try { ${resolvedScript} } catch { @@ -1276,7 +1333,8 @@ function runDesktopLifecycleScenario(mode: string) { let envelope: { fixtureFailure?: unknown; fixtureResult?: unknown } | undefined; try { if (marker) envelope = JSON.parse(marker); } catch { /* fixed category below */ } if (child.error || child.status !== 0 || child.signal || !envelope?.fixtureResult) { - const category = CLIENT_FIXTURE_FAILURE_CATEGORIES.find(value => value === envelope?.fixtureFailure) ?? "child_failed"; + const category = CLIENT_FIXTURE_FAILURE_CATEGORIES.find(value => value === envelope?.fixtureFailure) + ?? lastFixturePhase(child.stderr) ?? "child_failed"; throw new ClientStateProbeError(child.pid, child.status, child.signal, (child.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT", category); } return envelope.fixtureResult as Record; diff --git a/tests/codex-integration/codex-runtime.test.ts b/tests/codex-integration/codex-runtime.test.ts index 5991c05f7cf..f7dad943d19 100644 --- a/tests/codex-integration/codex-runtime.test.ts +++ b/tests/codex-integration/codex-runtime.test.ts @@ -859,10 +859,24 @@ describe("resolveCodexRuntime", () => { const previousHome = process.env.OPENCODEX_HOME; process.env.OPENCODEX_HOME = configDir; resetCodexRuntimeResolveCacheForTests(); - const deps = { env: { PATH: "" }, discoverAlternatives: false }; + const deps = { + env: { CODEX_HOME: join(configDir, "codex-home"), HOME: configDir, PATH: "" }, + platform: "linux" as const, + discoverAlternatives: false, + }; try { - const cached = resolveCodexRuntime(deps); + const missingCodex = Object.assign(new Error("spawn ENOENT"), { code: "ENOENT" }); + const cached = resolveCodexRuntime({ + ...deps, + configDir, + existsSync: () => false, + readdirSync: () => [], + execFileSync: ((_file, _args, options) => { + expect(options.timeout).toBe(8_000); + throw missingCodex; + }) as RuntimeExecFile, + }); expect(cached.runtime.source).toBe("fallback"); setCodexRuntimeResolveCacheForTests({ runtime: { command: "codex", version: null, source: "environment" }, diff --git a/tests/server/loopback-listener-integration.test.ts b/tests/server/loopback-listener-integration.test.ts index 062abbab23c..5cd0dd476ea 100644 --- a/tests/server/loopback-listener-integration.test.ts +++ b/tests/server/loopback-listener-integration.test.ts @@ -14,11 +14,12 @@ import { connect } from "node:net"; import { networkInterfaces, tmpdir } from "node:os"; import { join } from "node:path"; import { saveConfig } from "../../src/config"; -import { startServer } from "../../src/server"; +import { startServer, waitForFailedStartRollback } from "../../src/server"; import { runListenerShutdown } from "../../src/server/lifecycle"; import { AuxiliaryListenerBindError, findAvailablePort, + isAddrInUse, PortUnavailableError, setEphemeralPortAllocatorForTests, } from "../../src/server/ports"; @@ -73,6 +74,41 @@ async function startLoopbackTestServer(loopbackPort: number) { return startServer(publicPort); } +/** Fresh auxiliary draws one hub start may take; see startHubIngressServer. */ +const AUXILIARY_PORT_DRAWS = 3; + +/** + * Start a hub whose public listener the kernel assigns atomically, and read its port back. + * + * These cases used to probe a public port on 127.0.0.1, release it, and later bind it on the + * config's 0.0.0.0. Anything that took the port in between made startServer throw EADDRINUSE + * for a listener the case never asserted on (macOS lane=all run 39786507816, shard 3/3). Port 0 + * has no such window, so the public listener no longer has a probe at all. + * + * The management and data-loopback listeners cannot do the same: config requires them to name + * a concrete 1-65535 port before startServer runs, so a draw can still be taken before the + * server binds it. That outcome is not timing-dependent: startServer reports it as an + * AuxiliaryListenerBindError naming the listener, with the bind's EADDRINUSE as its cause, and + * only that verdict earns a fresh draw. Every other failure, including a wrong listener or a + * non-EADDRINUSE bind error, still fails the case on its first occurrence. + */ +async function startHubIngressServer( + configFor: (managementPort: number, loopbackPort: number) => OcxConfig, +): Promise<{ server: ReturnType; managementPort: number; loopbackPort: number }> { + for (let draw = 1; ; draw += 1) { + const managementPort = await freePort(); + const loopbackPort = await findAvailablePort(0, "127.0.0.1", { reservedPort: managementPort }); + saveConfig(configFor(managementPort, loopbackPort)); + try { + return { server: startServer(0), managementPort, loopbackPort }; + } catch (error) { + await waitForFailedStartRollback(error); + const auxiliaryPortTaken = error instanceof AuxiliaryListenerBindError && isAddrInUse(error.cause); + if (!auxiliaryPortTaken || draw >= AUXILIARY_PORT_DRAWS) throw error; + } + } +} + function firstNonLoopbackIPv4(): string | null { for (const entries of Object.values(networkInterfaces())) { for (const entry of entries ?? []) { @@ -121,10 +157,7 @@ afterEach(() => { describe("hub management ingress", () => { test("binds only loopback and serves GUI plus authenticated management routes", async () => { - const managementPort = await freePort(); - const publicPort = await findAvailablePort(0, "127.0.0.1", { reservedPort: managementPort }); - saveConfig(hubIngressConfig(managementPort)); - const server = startServer(publicPort); + const { server, managementPort } = await startHubIngressServer(managementPort => hubIngressConfig(managementPort)); try { const page = await fetch(`http://127.0.0.1:${managementPort}/`, { headers: { Host: "hub.example.test", "Tailscale-User-Login": "alice@example.test" }, @@ -158,9 +191,7 @@ describe("hub management ingress", () => { }, SERVER_BUDGET_MS); test("client export through management ingress names the public bound port and ingress refuses inference", async () => { - const managementPort = await freePort(); - const publicPort = await findAvailablePort(0, "127.0.0.1", { reservedPort: managementPort }); - saveConfig({ + const { server, managementPort } = await startHubIngressServer(managementPort => ({ ...hubIngressConfig(managementPort), port: 0, unauthenticatedLoopbackListener: { enabled: false }, @@ -174,8 +205,7 @@ describe("hub management ingress", () => { models: ["static-model"], }, }, - }); - const server = startServer(publicPort); + })); const base = `http://127.0.0.1:${managementPort}`; const headers = { Host: "hub.example.test", @@ -199,10 +229,7 @@ describe("hub management ingress", () => { }, SERVER_BUDGET_MS); test("default-denies every data, health, readiness, WebSocket, and unknown-static route", async () => { - const managementPort = await freePort(); - const publicPort = await findAvailablePort(0, "127.0.0.1", { reservedPort: managementPort }); - saveConfig(hubIngressConfig(managementPort)); - const server = startServer(publicPort); + const { server, managementPort } = await startHubIngressServer(managementPort => hubIngressConfig(managementPort)); const base = `http://127.0.0.1:${managementPort}`; try { const denied: Array<{ path: string; headers?: Record }> = [ @@ -249,11 +276,8 @@ describe("hub management ingress", () => { }); test("normal shutdown closes public, data-loopback, and management listeners", async () => { - const managementPort = await freePort(); - const loopbackPort = await findAvailablePort(0, "127.0.0.1", { reservedPort: managementPort }); - const publicPort = await findAvailablePort(0, "127.0.0.1", { reservedPort: loopbackPort }); - saveConfig(hubIngressConfig(managementPort, loopbackPort)); - const server = startServer(publicPort); + const { server, managementPort, loopbackPort } = await startHubIngressServer(hubIngressConfig); + const publicPort = server.port!; await server.stop(true); for (const port of [publicPort, loopbackPort, managementPort]) { const rebound = Bun.serve({ port, hostname: "127.0.0.1", fetch: () => new Response("ok") }); From bd5c2ea906741a4201c6f35bb180e7a9a112a4fd Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 24 Oct 2026 14:07:23 +0900 Subject: [PATCH 008/101] test: keep cold first-exec and PowerShell start out of two timed cases (#7598) Keep the cold first-exec of a fresh Bun copy and the PowerShell start out of two timed cases: the PATH-fallback cases share one warmed Bun copy created in a budgeted beforeAll, and the CurrentUser Root certificate inspection is replayed once before its timed case. The 10s product deadline is unchanged; a new seam test drives a deadline breach to unknown on every platform. Refs #7578 #7569 --- .../windows-certificate-trust.ts | 58 +++++++++++++++---- tests/cli/ocx-launcher-runtime.test.ts | 45 +++++++++----- .../desktop-compatibility-trust.test.ts | 44 +++++++++++++- 3 files changed, 117 insertions(+), 30 deletions(-) diff --git a/src/codex/desktop-compatibility/windows-certificate-trust.ts b/src/codex/desktop-compatibility/windows-certificate-trust.ts index d26e2f828c3..1ad9e3be883 100644 --- a/src/codex/desktop-compatibility/windows-certificate-trust.ts +++ b/src/codex/desktop-compatibility/windows-certificate-trust.ts @@ -9,6 +9,26 @@ export type DesktopCertificateTrust = "trusted" | "not-trusted" | "unknown"; export type TrustOperation = "inspect" | "trust" | "remove"; export type CertificateTrustRunner = (operation: TrustOperation, certificateDer: string, fingerprint: string) => Promise; +/** Public status is an interactive read; a store that cannot answer within it reports `unknown`. */ +export const TRUST_INSPECT_DEADLINE_MS = 10_000; +/** A mutation may wait on the user's certificate prompt. */ +export const TRUST_CHANGE_DEADLINE_MS = 120_000; + +/** The slice of `child_process.execFile` the runner uses, so a deadline breach can be driven without a real store. */ +export type TrustCommandExec = ( + file: string, + args: string[], + options: { timeout: number; maxBuffer: number; windowsHide: boolean; encoding: "utf8" }, + callback: (error: Error | null, stdout: string) => void, +) => { stdin?: { on(event: "error", listener: () => void): unknown; end(chunk: string): unknown } | null }; + +export type PowerShellTrustRunnerOptions = { + inspectDeadlineMs?: number; + platform?: NodeJS.Platform; + exec?: TrustCommandExec; + resolvePowerShell?: () => string; +}; + const SCRIPT = [ "$ErrorActionPreference='Stop'", "$inputData=[Console]::In.ReadToEnd()|ConvertFrom-Json", @@ -31,18 +51,32 @@ const SCRIPT = [ "}finally{$store.Close();$store.Dispose();$cert.Dispose()}", ].join("\n"); -const run: CertificateTrustRunner = (operation, certificateDer, fingerprint) => { - if (process.platform !== "win32") return Promise.resolve("unknown"); - if (operation !== "inspect" && isTestHomeGuardArmed()) return Promise.resolve("unknown"); - return new Promise(resolve => { - const child = execFile(resolveTrustedWindowsPowerShellExe(), ["-NoProfile", "-NonInteractive", "-Command", SCRIPT], { - timeout: operation === "inspect" ? 10_000 : 120_000, maxBuffer: 4096, windowsHide: true, encoding: "utf8", - }, (error, stdout) => resolve(!error && (stdout.trim() === "trusted" || stdout.trim() === "not-trusted") - ? stdout.trim() as DesktopCertificateTrust : "unknown")); - child.stdin?.on("error", () => { /* completion and readback decide the result */ }); - child.stdin?.end(JSON.stringify({ operation, certificate: certificateDer, fingerprint })); - }); -}; +/** + * The real CurrentUser Root runner. Any failure, including a deadline breach, is `unknown`, which + * never authorizes a write. The options exist so a test can replay this exact call with a cold-start + * deadline and can drive a breach deterministically; production uses the defaults. + */ +export function createPowerShellCertificateTrustRunner({ + inspectDeadlineMs = TRUST_INSPECT_DEADLINE_MS, + platform = process.platform, + exec = execFile as unknown as TrustCommandExec, + resolvePowerShell = resolveTrustedWindowsPowerShellExe, +}: PowerShellTrustRunnerOptions = {}): CertificateTrustRunner { + return (operation, certificateDer, fingerprint) => { + if (platform !== "win32") return Promise.resolve("unknown"); + if (operation !== "inspect" && isTestHomeGuardArmed()) return Promise.resolve("unknown"); + return new Promise(resolve => { + const child = exec(resolvePowerShell(), ["-NoProfile", "-NonInteractive", "-Command", SCRIPT], { + timeout: operation === "inspect" ? inspectDeadlineMs : TRUST_CHANGE_DEADLINE_MS, maxBuffer: 4096, windowsHide: true, encoding: "utf8", + }, (error, stdout) => resolve(!error && (stdout.trim() === "trusted" || stdout.trim() === "not-trusted") + ? stdout.trim() as DesktopCertificateTrust : "unknown")); + child.stdin?.on("error", () => { /* completion and readback decide the result */ }); + child.stdin?.end(JSON.stringify({ operation, certificate: certificateDer, fingerprint })); + }); + }; +} + +const run = createPowerShellCertificateTrustRunner(); /** Public status cannot create a certificate, decrypt a key or alter OS trust. */ export async function inspectWindowsCertificateTrust(certPem: string, expectedFingerprint: string, runner: CertificateTrustRunner = run): Promise { diff --git a/tests/cli/ocx-launcher-runtime.test.ts b/tests/cli/ocx-launcher-runtime.test.ts index aa596031ef9..af4ac15d344 100644 --- a/tests/cli/ocx-launcher-runtime.test.ts +++ b/tests/cli/ocx-launcher-runtime.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import { chmodSync, copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, realpathSync, readlinkSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; import { createServer } from "node:net"; @@ -10,6 +10,7 @@ import { findDesktopCli, findPathBun, type PathBunIo } from "../../src/lib/bun-p import { REAL_BUN_MIN_BYTES } from "../../src/lib/bun-binary-validator.mjs"; import { killProxy } from "../../src/lib/process-control"; import { repoPath } from "../helpers/repo-root"; +import { COLD_SPAWN_WARMUP_HOOK_BUDGET_MS, warmColdSpawn } from "../helpers/cold-spawn-warmup"; import { INTERNAL_DEADLINE_MS, SPAWN_BUDGET_MS } from "../helpers/test-budget"; import { trustedFixtureRoot } from "../helpers/trusted-fixture-root"; @@ -762,14 +763,29 @@ describe("real PATH Bun validation", () => { }); }); -function warmCopiedBun(path: string): void { - if (process.platform !== "win32") return; - const warmed = spawnSync(path, ["--version"], { - encoding: "utf8", - timeout: WINDOWS_COLD_SPAWN_MS, - windowsHide: true, +/** + * The one real Bun copy every PATH-fallback case puts on PATH, created and run once in setup. + * + * The first exec of a freshly copied bun binary is held by the OS's first-run scan: about 1s on an + * idle macOS host (later execs of the same path take ~3ms) and well over that for a cold win32 + * bun.exe. A copy per case put that scan inside each case's 5s budget and inside the launcher's own + * 5s PATH probe, so the verdict tracked host load (#7578). The scan is keyed by path, so a hardlink + * does not inherit it; sharing the warmed path is what keeps it out of the measured window. + * The launcher only reads and executes this file, so sharing it changes nothing a case asserts. + */ +let sharedPathBun: { root: string; dir: string; bun: string } | undefined; + +async function createWarmPathBun(): Promise { + const root = realpathSync.native(pathFixtureRoot("ocx-path-launcher-bun-")); + const dir = join(root, "path"); mkdirSync(dir); + const bun = join(dir, process.platform === "win32" ? "bun.exe" : "bun"); + copyFileSync(process.execPath, bun); + chmodSync(bun, 0o755); + sharedPathBun = { root, dir, bun }; + await warmColdSpawn("bun/launcher-path-fallback-copy", deadlineMs => { + const warmed = spawnSync(bun, ["--version"], { encoding: "utf8", timeout: deadlineMs, windowsHide: true }); + if (warmed.status !== 0) throw new Error(`[cold-spawn-warmup] PATH Bun copy did not run: status=${String(warmed.status)} ${warmed.stderr}`); }); - expect(warmed.status, warmed.stderr).toBe(0); } function launcherFallbackFixture(mode: "missing" | "bundled" | "repair" | "broken" | "override" | "none" | "inspection" | "rejected") { @@ -783,17 +799,14 @@ function launcherFallbackFixture(mode: "missing" | "bundled" | "repair" | "broke copyFileSync(BIN_OCX, join(root, "bin", "ocx.mjs")); writeFileSync(join(root, "package.json"), JSON.stringify({ version: "2.81.0", dependencies: { bun: "1.4.2" } })); writeFileSync(join(root, "src", "cli", "index.ts"), 'console.log(JSON.stringify({source:process.env.OCX_BUN_RUNTIME_SOURCE,path:process.env.OCX_BUN_RUNTIME_PATH}));'); - const pathDir = join(root, "path"); mkdirSync(pathDir); + const shared = mode !== "none" && mode !== "rejected"; + if (shared && !sharedPathBun) throw new Error("the shared PATH Bun is created by the activation suite's beforeAll"); + const pathDir = shared ? sharedPathBun!.dir : join(root, "path"); + if (!shared) mkdirSync(pathDir); const pathBun = join(pathDir, process.platform === "win32" ? "bun.exe" : "bun"); if (mode === "rejected") { writeFileSync(pathBun, '#!/bin/sh\nprintf "2.0.0\\n"\n#' + "x".repeat(REAL_BUN_MIN_BYTES)); chmodSync(pathBun, 0o755); - } else if (mode !== "none") { - copyFileSync(process.execPath, pathBun); - chmodSync(pathBun, 0o755); - // The product PATH probe is 5s for both execs together. Warm the copy first so - // that budget measures a scanned binary; a cold win32 bun.exe is the flake. - warmCopiedBun(pathBun); } const bundleDir = join(root, "node_modules", "bun"); if (["bundled", "repair", "broken", "inspection"].includes(mode)) { @@ -820,6 +833,8 @@ function launcherFallbackFixture(mode: "missing" | "bundled" | "repair" | "broke } describe.skipIf(!nodeAvailable)("Node launcher PATH fallback activation", () => { + beforeAll(async () => { await createWarmPathBun(); }, COLD_SPAWN_WARMUP_HOOK_BUDGET_MS); + afterAll(() => { if (sharedPathBun) removeTree(sharedPathBun.root); sharedPathBun = undefined; }); test.each(["missing", "broken", "inspection"] as const)("%s bundle reaches validated PATH Bun", mode => { const result = launcherFallbackFixture(mode); expect(result.status).toBe(0); diff --git a/tests/clients/desktop-compatibility-trust.test.ts b/tests/clients/desktop-compatibility-trust.test.ts index db580c72246..38232ead423 100644 --- a/tests/clients/desktop-compatibility-trust.test.ts +++ b/tests/clients/desktop-compatibility-trust.test.ts @@ -1,8 +1,12 @@ -import { describe, expect, test } from "bun:test"; +import { beforeAll, describe, expect, test } from "bun:test"; import { randomUUID, X509Certificate } from "node:crypto"; import { createCertificateAuthority } from "../../src/claude/intercept/local-ca"; import type { StoredDesktopAuthority } from "../../src/codex/desktop-compatibility/certificate-store"; -import { createWindowsCertificateTrust, inspectWindowsCertificateTrust, type DesktopCertificateTrust, type TrustOperation } from "../../src/codex/desktop-compatibility/windows-certificate-trust"; +import { + createPowerShellCertificateTrustRunner, createWindowsCertificateTrust, inspectWindowsCertificateTrust, TRUST_INSPECT_DEADLINE_MS, + type DesktopCertificateTrust, type TrustCommandExec, type TrustOperation, +} from "../../src/codex/desktop-compatibility/windows-certificate-trust"; +import { COLD_SPAWN_WARMUP_HOOK_BUDGET_MS, warmColdSpawn } from "../helpers/cold-spawn-warmup"; function fixture(serverAuthOnly = true): StoredDesktopAuthority { const commonName = `OpenCodex Codex Desktop ${randomUUID()}`; @@ -73,7 +77,41 @@ describe("exact Windows compatibility certificate trust", () => { release("trusted"); expect(await first).toBe("trusted"); }); - test.skipIf(process.platform !== "win32")("real CurrentUser Root inspection remains read-only for an unregistered synthetic CA", async () => { + test("a store read that outlives the status deadline is unknown and requests nothing but an inspection", async () => { + // The runner's own deadline, driven without a real store: execFile reports a killed child the way + // node does when `timeout` elapses, with whatever the child printed before the kill. This is the + // half of the real-store case that depends on timing, so it is proven here on every platform. + const value = fixture(), calls: { timeout: number; input: string }[] = []; + const exec: TrustCommandExec = (_file, _args, options, callback) => { + let input = ""; + queueMicrotask(() => { + calls.push({ timeout: options.timeout, input }); + callback(Object.assign(new Error("Command failed: powershell.exe"), { killed: true, signal: "SIGTERM", code: null }), "not-trusted"); + }); + return { stdin: { on: () => undefined, end: (chunk: string) => { input = chunk; } } }; + }; + const runner = createPowerShellCertificateTrustRunner({ platform: "win32", exec, resolvePowerShell: () => "powershell.exe" }); + expect(await inspectWindowsCertificateTrust(value.authority.certPem, value.fingerprint, runner)).toBe("unknown"); + expect(calls).toHaveLength(1); + expect(calls[0].timeout).toBe(TRUST_INSPECT_DEADLINE_MS); + expect(JSON.parse(calls[0].input).operation).toBe("inspect"); + }); +}); + +describe.skipIf(process.platform !== "win32")("exact Windows compatibility certificate trust on the real store", () => { + // The first PowerShell in a shard pays process start, .NET load and the X509 store open; on a + // loaded runner that crossed the 10s status deadline and read as `unknown` (#7569). Replaying the + // same inspection once here leaves the unchanged product deadline below measuring a warm read. + beforeAll(async () => { + await warmColdSpawn("powershell/desktop-certificate-trust-inspect", async deadlineMs => { + const value = fixture(); + const runner = createPowerShellCertificateTrustRunner({ inspectDeadlineMs: deadlineMs }); + const state = await inspectWindowsCertificateTrust(value.authority.certPem, value.fingerprint, runner); + if (state === "unknown") throw new Error(`[cold-spawn-warmup] real CurrentUser Root inspection gave no answer within ${deadlineMs}ms`); + }); + }, COLD_SPAWN_WARMUP_HOOK_BUDGET_MS); + + test("real CurrentUser Root inspection remains read-only for an unregistered synthetic CA", async () => { const value = fixture(); expect(await inspectWindowsCertificateTrust(value.authority.certPem, value.fingerprint)).toBe("not-trusted"); }, 15_000); From 46f23cab5eb54ef8d1e1cf358861d3b6a74e68fa Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 24 Oct 2026 18:10:42 +0900 Subject: [PATCH 009/101] feat(usage): price and seed Kimi K3.1 Pro on Kimi Code and Moonshot (#7657) * feat(usage): price and seed Kimi K3.1 Pro on Kimi Code and Moonshot Kimi Code's coding endpoint now lists k3.1-pro and k3.1-pro-highspeed (live GET /coding/v1/models, context_length 1048576). Seed both on the kimi, kimi-code and kimi-responses presets with a 1M window and the K3 low/high/max ladder (thinking-only, default max), and add expected-price rows from the official table (platform.kimi.ai/docs/pricing/chat, 2026-10-24): K3.1 Pro $2/$10, cache hit $0.20, 5m cache write $2; Highspeed $4/$20/$0.20/$4. The moonshot API preset gets price rows for kimi-k3.1-pro and kimi-k3.1-pro-highspeed. Re-verified K3, K2.7 Code (+highspeed) and K2.6 against the current table (unchanged) and moved their verifiedAt to 2026-10-24. kimi-k2.5 left the table (discontinued 2026-08-31), so its rows keep the 2026-07-20 snapshot with a source that says so. * docs(structure): name Kimi K3.1 Pro seeds and price rows in owning docs --- .../src/content/docs/guides/providers.md | 12 +-- src/providers/registry/model-seeds.ts | 10 ++- src/usage/expected-prices.ts | 74 ++++++++++++------- structure/dashboard-and-usage.md | 2 +- structure/providers-and-adapters.md | 2 +- .../provider-registry-parity.test.ts | 10 ++- tests/usage/usage-cost.test.ts | 32 ++++++++ 7 files changed, 102 insertions(+), 40 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 41caa0b660f..e9bdbb56678 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -316,7 +316,7 @@ ocx logout | --- | --- | --- | --- | | `xai` | `openai-chat` | `https://cli-chat-proxy.grok.com/v1` | OAuth uses the separate Grok CLI subscription gateway. The API-key override uses `https://api.x.ai/v1` and may inject Priority Processing. Live-first Grok catalog; `grok-4.5` is the fallback default. | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Claude models; live model list fetched from `/v1/models`. | -| `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi Code Plan coding models. Defaults to the stable `kimi-for-coding` alias (currently K2.8 Preview): 1M-token context window, adjustable `low`/`high`/`max` thinking (default `max`), text + image input. Retired `kimi-k2.x` selections are migrated to the alias on upgrade. | +| `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi Code Plan coding models. Defaults to the stable `kimi-for-coding` alias (currently K2.8 Preview): 1M-token context window, adjustable `low`/`high`/`max` thinking (default `max`), text + image input. `k3.1-pro` and `k3.1-pro-highspeed` (K3.1 Pro) are also listed: 1M-token context, thinking-only on the same ladder (default `max`). Retired `kimi-k2.x` selections are migrated to the alias on upgrade. | | `kimi-responses` | `openai-responses` | `https://api.kimi.com/coding/v1` | Same Kimi account login (reuses the `kimi` OAuth credential) over the OpenAI Responses wire. Same model roster and capabilities as `kimi`; thinking content stays encrypted server-side, tool calls and results stay visible. | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research subscription gateway (same backend Hermes Agent uses). Device-grant login against `portal.nousresearch.com`; the access token is the per-request inference JWT. Mixed paid + `:free` model catalog (`tencent/hy3:free`, `stepfun/step-3.7-flash:free`, ...) discovered live from the signed-in account. Refresh tokens are single-use and rotated on every refresh. | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | The dashboard Login and Add account buttons offer Builder ID, Google, GitHub device login, or Kiro CLI. Native device login adds an account without signing `kiro-cli` out. The Kiro CLI choice imports or starts a CLI session; its Add account workflow temporarily switches the CLI account and restores it on cancellation or failure. | @@ -388,10 +388,12 @@ to avoid its `assistant must not be empty` rejection. Nonempty text, whitespace, reasoning and tool calls/results are retained; stored client history is not rewritten. An empty message referenced by an `item_reference` in the same input is retained rather than leaving a dangling reference. -Kimi Coding usage prices for `k3`, `k3[1m]`, and `k3-256k` are **API-reference estimates** across -`kimi`, `kimi-code`, and `kimi-responses`, using the published default 5-minute cache-write rate. -They do not reproduce Code Plan billing or quota: K3's 1M variant consumes about twice the quota -of `k3-256k`. `kimi-for-coding` now points to K2.8 Preview, so its former K2.7 price is no longer +Kimi Coding usage prices for `k3`, `k3[1m]`, `k3-256k`, `k3.1-pro`, and `k3.1-pro-highspeed` are +**API-reference estimates** across `kimi`, `kimi-code`, and `kimi-responses`, using the published +default 5-minute cache-write rate (the `moonshot` API preset prices the same models as `kimi-k3`, +`kimi-k3.1-pro`, and `kimi-k3.1-pro-highspeed`). They do not reproduce Code Plan billing or quota: +K3's 1M variant consumes about twice the quota of `k3-256k`, and K3.1 Pro Highspeed about twice +that of `k3.1-pro`. `kimi-for-coding` now points to K2.8 Preview, so its former K2.7 price is no longer used; its estimate remains unavailable unless the operator supplies `modelCosts`. Unknown-price routing policies and cost caps that exclude unknown estimates can therefore exclude this alias. See [Kimi's model configuration](https://www.kimi.com/code/docs/en/kimi-code/models.html) and diff --git a/src/providers/registry/model-seeds.ts b/src/providers/registry/model-seeds.ts index 674b37fe607..23bca371989 100644 --- a/src/providers/registry/model-seeds.ts +++ b/src/providers/registry/model-seeds.ts @@ -753,6 +753,12 @@ export const ALIBABA_TOKEN_PLAN_PRESERVE_REASONING = [ export const KIMI_K3_STANDARD_CONTEXT_WINDOW = 262_144; export const KIMI_K3_1M_CONTEXT_WINDOW = 1_048_576; export const KIMI_CODING_K3_MODELS = ["k3", "k3[1m]", "k3-256k"]; +// 261024 Kimi K3.1 Pro: live GET /coding/v1/models now also lists k3.1-pro and +// k3.1-pro-highspeed, both advertising context_length 1048576 (flat 1M, no entitlement +// alias). Both run thinking-only: the endpoint rejects thinking-disabled requests with +// HTTP 400, and an absent effort defaults to max — the same low/high/max ladder as k3. +// Evidence: https://www.kimi.com/code/docs/en/kimi-code/models.html +export const KIMI_CODING_K31_MODELS = ["k3.1-pro", "k3.1-pro-highspeed"]; // 260921 Kimi K2.8: `kimi-for-coding` is the stable subscription alias Moonshot re-points // at each coding release. Live GET /coding/v1/models lists only kimi-for-coding[-highspeed], // k3, k3-256k — the k2.x ids are retired from the subscription endpoint. Since K2.8 Preview @@ -761,7 +767,7 @@ export const KIMI_CODING_K3_MODELS = ["k3", "k3[1m]", "k3-256k"]; // with "model token limit: 1048576" beyond that. // Evidence: https://www.kimi.com/code/docs/en/kimi-code/models.html export const KIMI_CODING_K28_MODELS = ["kimi-for-coding"]; -export const KIMI_CODING_LIVE_MODELS = [...KIMI_CODING_K3_MODELS, ...KIMI_CODING_K28_MODELS]; +export const KIMI_CODING_LIVE_MODELS = [...KIMI_CODING_K3_MODELS, ...KIMI_CODING_K31_MODELS, ...KIMI_CODING_K28_MODELS]; export const KIMI_CODING_ADJUSTABLE_THINKING_MODELS = [...KIMI_CODING_LIVE_MODELS]; export const KIMI_LEGACY_API_MODELS = ["kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6", "kimi-k2.5"]; export const KIMI_API_MODELS = ["kimi-k3", ...KIMI_LEGACY_API_MODELS]; @@ -884,7 +890,7 @@ export const NVIDIA_NIM_NO_VISION_MODELS = [ "poolside/laguna-xs-2.1", "z-ai/glm-5.3", "z-ai/glm-5.2", ]; export const KIMI_CODING_MODEL_CONTEXT_WINDOWS: Record = Object.fromEntries( - KIMI_CODING_LIVE_MODELS.map(id => [id, (id === "k3[1m]" || KIMI_CODING_K28_MODELS.includes(id)) ? KIMI_K3_1M_CONTEXT_WINDOW : KIMI_K3_STANDARD_CONTEXT_WINDOW]), + KIMI_CODING_LIVE_MODELS.map(id => [id, (id === "k3[1m]" || KIMI_CODING_K31_MODELS.includes(id) || KIMI_CODING_K28_MODELS.includes(id)) ? KIMI_K3_1M_CONTEXT_WINDOW : KIMI_K3_STANDARD_CONTEXT_WINDOW]), ); export const KIMI_CODING_MODEL_INPUT_MODALITIES = Object.fromEntries( KIMI_CODING_ADJUSTABLE_THINKING_MODELS.map(id => [id, ["text", "image"]]), diff --git a/src/usage/expected-prices.ts b/src/usage/expected-prices.ts index 5ca726e6624..e56678efba1 100644 --- a/src/usage/expected-prices.ts +++ b/src/usage/expected-prices.ts @@ -97,6 +97,10 @@ const GEMINI_38_FLASH: Cost4 = { input: 0.75, output: 3.75, cacheRead: 0.075, ca const GEMINI_4_ARGON: Cost4 = { input: 2, output: 10, cacheRead: 0.1, cacheWrite: 0 }; const MINIMAX_M21_HIGHSPEED: Cost4 = { input: 0.6, output: 2.4, cacheRead: 0.03, cacheWrite: 0.375 }; const KIMI_K3: Cost4 = { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3 }; +// K3.1 Pro and its highspeed variant: flat 1M-context pricing (no context tiering). Like K3, +// cacheWrite is the default 5-minute TTL rate; the 1h tier ($4 / $8) is not modelled. +const KIMI_K31_PRO: Cost4 = { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2 }; +const KIMI_K31_PRO_HIGHSPEED: Cost4 = { input: 4, output: 20, cacheRead: 0.2, cacheWrite: 4 }; const KIMI_K27_CODE: Cost4 = { input: 0.95, output: 4, cacheRead: 0.19, cacheWrite: 0.95 }; const KIMI_K27_CODE_HIGHSPEED: Cost4 = { input: 1.9, output: 8, cacheRead: 0.38, cacheWrite: 1.9 }; const KIMI_K26: Cost4 = { input: 0.95, output: 4, cacheRead: 0.16, cacheWrite: 0.95 }; @@ -236,12 +240,19 @@ const DEEPSEEK_PRICING = "https://api-docs.deepseek.com/quick_start/pricing-deta * cache-write charge. */ const DEEPSEEK_V41_FLASH: Cost4 = { input: 0.3, output: 1.2, cacheRead: 0.006, cacheWrite: 0 }; -// Historical K2 rows retain their 2026-07-20 verification; they do not price retargeted aliases. -const KIMI_PRICING = "https://platform.kimi.ai/docs/pricing (historical official table; cacheWrite derived from cache-miss input)"; +// K2.7 Code / K2.6 were re-read on the official table 2026-10-24 (unchanged). The K2 series +// closes to new users 2026-10-31 and retires by end of December 2026. kimi-k2.5 was +// discontinued 2026-08-31 and left the table, so its row keeps the 2026-07-20 snapshot for +// pricing historical usage. None of these price retargeted aliases. +const KIMI_PRICING = "https://platform.kimi.ai/docs/pricing/chat (official K2 series table; cacheWrite derived from cache-miss input)"; +const KIMI_K25_PRICING = "https://platform.kimi.ai/docs/pricing (historical official table, 2026-07-20; kimi-k2.5 discontinued 2026-08-31 per https://platform.kimi.ai/docs/models; cacheWrite derived from cache-miss input)"; // K3 publishes a separate cache-write price: $3/1M for the default 5-minute TTL. // Coding presets share an endpoint/product, but these API reference estimates are not plan // billing or quota: K3's 1M model consumes about twice the quota of k3-256k. const KIMI_K3_PRICING = "https://platform.kimi.ai/docs/pricing/chat (API reference estimate; default 5-minute cache-write rate, not Code Plan billing/quota); https://www.kimi.com/code/docs/en/kimi-code/models.html"; +// K3.1 Pro shares the K3 table and caveats; the highspeed variant consumes about twice the +// Code Plan quota of the regular one, which these list-price estimates do not reproduce. +const KIMI_K31_PRICING = KIMI_K3_PRICING; // Z.AI publishes one USD table for the international surface; the Coding Plan // subscription and the domestic bigmodel.cn endpoints bill differently // (subscription quota / CNY tiers), so every GLM row below is verified-derived: @@ -437,34 +448,43 @@ export const EXPECTED_PRICE_OVERLAYS: readonly ExpectedPriceOverlay[] = [ { provider: "google-antigravity", modelId: "claude-opus-5-5-medium", cost4: CLAUDE_OPUS_55, source: `derived: Anthropic Claude Opus 5.5 reference price; ${ANTHROPIC_PRICING}`, verifiedAt: "2026-10-03", status: "verified-derived" }, { provider: "google-antigravity", modelId: "claude-opus-5-5-high", cost4: CLAUDE_OPUS_55, source: `derived: Anthropic Claude Opus 5.5 reference price; ${ANTHROPIC_PRICING}`, verifiedAt: "2026-10-03", status: "verified-derived" }, { provider: "google-antigravity", modelId: "gpt-oss-120b-medium", cost4: { input: 0.03, output: 0.15, cacheRead: 0, cacheWrite: 0 }, source: "derived: gpt-oss-120b open-weights — OpenRouter advertised lowest https://openrouter.ai/openai/gpt-oss-120b/providers", verifiedAt: "2026-07-20", status: "verified-derived" }, - // Kimi / Moonshot — official price tables are now published (2026-07-20 re-check; - // previously empty). kimi = Kimi Code OAuth surface, moonshot = CN key surface, - // kimi-code = API key surface (expected list price, not actual billing). - { provider: "kimi", modelId: "k3", cost4: KIMI_K3, source: KIMI_K3_PRICING, verifiedAt: "2026-09-23", status: "verified-derived" }, - { provider: "kimi", modelId: "k3[1m]", cost4: KIMI_K3, source: `derived: local alias sent as k3; ${KIMI_K3_PRICING}`, verifiedAt: "2026-09-23", status: "verified-derived" }, - { provider: "kimi", modelId: "k3-256k", cost4: KIMI_K3, source: `derived: K3 with a fixed 256K ceiling; ${KIMI_K3_PRICING}`, verifiedAt: "2026-09-23", status: "verified-derived" }, - { provider: "kimi", modelId: "kimi-k2.7-code", cost4: KIMI_K27_CODE, source: KIMI_PRICING, verifiedAt: "2026-07-20", status: "verified-derived" }, - { provider: "kimi", modelId: "kimi-k2.7-code-highspeed", cost4: KIMI_K27_CODE_HIGHSPEED, source: KIMI_PRICING, verifiedAt: "2026-07-20", status: "verified-derived" }, - { provider: "kimi", modelId: "kimi-k2.6", cost4: KIMI_K26, source: KIMI_PRICING, verifiedAt: "2026-07-20", status: "verified-derived" }, - { provider: "kimi", modelId: "kimi-k2.5", cost4: KIMI_K25, source: KIMI_PRICING, verifiedAt: "2026-07-20", status: "verified-derived" }, + // Kimi / Moonshot — official tables re-read 2026-10-24 (platform.kimi.ai/docs/pricing/chat). + // kimi / kimi-responses = Kimi Code OAuth surface, kimi-code = its API-key form, moonshot = + // the separately billed platform API (api.moonshot.ai, or .cn). Coding-surface rows are + // expected list prices, not Code Plan billing or quota. + { provider: "kimi", modelId: "k3", cost4: KIMI_K3, source: KIMI_K3_PRICING, verifiedAt: "2026-10-24", status: "verified-derived" }, + { provider: "kimi", modelId: "k3[1m]", cost4: KIMI_K3, source: `derived: local alias sent as k3; ${KIMI_K3_PRICING}`, verifiedAt: "2026-10-24", status: "verified-derived" }, + { provider: "kimi", modelId: "k3-256k", cost4: KIMI_K3, source: `derived: K3 with a fixed 256K ceiling; ${KIMI_K3_PRICING}`, verifiedAt: "2026-10-24", status: "verified-derived" }, + { provider: "kimi", modelId: "k3.1-pro", cost4: KIMI_K31_PRO, source: KIMI_K31_PRICING, verifiedAt: "2026-10-24", status: "verified-derived" }, + { provider: "kimi", modelId: "k3.1-pro-highspeed", cost4: KIMI_K31_PRO_HIGHSPEED, source: KIMI_K31_PRICING, verifiedAt: "2026-10-24", status: "verified-derived" }, + { provider: "kimi", modelId: "kimi-k2.7-code", cost4: KIMI_K27_CODE, source: KIMI_PRICING, verifiedAt: "2026-10-24", status: "verified-derived" }, + { provider: "kimi", modelId: "kimi-k2.7-code-highspeed", cost4: KIMI_K27_CODE_HIGHSPEED, source: KIMI_PRICING, verifiedAt: "2026-10-24", status: "verified-derived" }, + { provider: "kimi", modelId: "kimi-k2.6", cost4: KIMI_K26, source: KIMI_PRICING, verifiedAt: "2026-10-24", status: "verified-derived" }, + { provider: "kimi", modelId: "kimi-k2.5", cost4: KIMI_K25, source: KIMI_K25_PRICING, verifiedAt: "2026-07-20", status: "verified-derived" }, // kimi-for-coding now serves K2.8 Preview. No verified K2.8 price: keep all three // Coding presets unknown rather than reusing the alias's retired K2.7 price. - { provider: "moonshot", modelId: "kimi-k3", cost4: KIMI_K3, source: KIMI_K3_PRICING, verifiedAt: "2026-09-23", status: "verified-derived" }, - { provider: "moonshot", modelId: "kimi-k2.7-code", cost4: KIMI_K27_CODE, source: KIMI_PRICING, verifiedAt: "2026-07-20", status: "verified-derived" }, - { provider: "moonshot", modelId: "kimi-k2.7-code-highspeed", cost4: KIMI_K27_CODE_HIGHSPEED, source: KIMI_PRICING, verifiedAt: "2026-07-20", status: "verified-derived" }, - { provider: "moonshot", modelId: "kimi-k2.6", cost4: KIMI_K26, source: KIMI_PRICING, verifiedAt: "2026-07-20", status: "verified-derived" }, - { provider: "moonshot", modelId: "kimi-k2.5", cost4: KIMI_K25, source: KIMI_PRICING, verifiedAt: "2026-07-20", status: "verified-derived" }, - { provider: "kimi-code", modelId: "k3", cost4: KIMI_K3, source: KIMI_K3_PRICING, verifiedAt: "2026-09-23", status: "verified-derived" }, - { provider: "kimi-code", modelId: "k3[1m]", cost4: KIMI_K3, source: `derived: local alias sent as k3; ${KIMI_K3_PRICING}`, verifiedAt: "2026-09-23", status: "verified-derived" }, - { provider: "kimi-code", modelId: "k3-256k", cost4: KIMI_K3, source: `derived: K3 with a fixed 256K ceiling; ${KIMI_K3_PRICING}`, verifiedAt: "2026-09-23", status: "verified-derived" }, - { provider: "kimi-code", modelId: "kimi-k2.7-code", cost4: KIMI_K27_CODE, source: KIMI_PRICING, verifiedAt: "2026-07-20", status: "verified-derived" }, - { provider: "kimi-code", modelId: "kimi-k2.7-code-highspeed", cost4: KIMI_K27_CODE_HIGHSPEED, source: KIMI_PRICING, verifiedAt: "2026-07-20", status: "verified-derived" }, - { provider: "kimi-code", modelId: "kimi-k2.6", cost4: KIMI_K26, source: KIMI_PRICING, verifiedAt: "2026-07-20", status: "verified-derived" }, - { provider: "kimi-code", modelId: "kimi-k2.5", cost4: KIMI_K25, source: KIMI_PRICING, verifiedAt: "2026-07-20", status: "verified-derived" }, + { provider: "moonshot", modelId: "kimi-k3", cost4: KIMI_K3, source: KIMI_K3_PRICING, verifiedAt: "2026-10-24", status: "verified-derived" }, + { provider: "moonshot", modelId: "kimi-k3.1-pro", cost4: KIMI_K31_PRO, source: KIMI_K31_PRICING, verifiedAt: "2026-10-24", status: "verified-derived" }, + { provider: "moonshot", modelId: "kimi-k3.1-pro-highspeed", cost4: KIMI_K31_PRO_HIGHSPEED, source: KIMI_K31_PRICING, verifiedAt: "2026-10-24", status: "verified-derived" }, + { provider: "moonshot", modelId: "kimi-k2.7-code", cost4: KIMI_K27_CODE, source: KIMI_PRICING, verifiedAt: "2026-10-24", status: "verified-derived" }, + { provider: "moonshot", modelId: "kimi-k2.7-code-highspeed", cost4: KIMI_K27_CODE_HIGHSPEED, source: KIMI_PRICING, verifiedAt: "2026-10-24", status: "verified-derived" }, + { provider: "moonshot", modelId: "kimi-k2.6", cost4: KIMI_K26, source: KIMI_PRICING, verifiedAt: "2026-10-24", status: "verified-derived" }, + { provider: "moonshot", modelId: "kimi-k2.5", cost4: KIMI_K25, source: KIMI_K25_PRICING, verifiedAt: "2026-07-20", status: "verified-derived" }, + { provider: "kimi-code", modelId: "k3", cost4: KIMI_K3, source: KIMI_K3_PRICING, verifiedAt: "2026-10-24", status: "verified-derived" }, + { provider: "kimi-code", modelId: "k3[1m]", cost4: KIMI_K3, source: `derived: local alias sent as k3; ${KIMI_K3_PRICING}`, verifiedAt: "2026-10-24", status: "verified-derived" }, + { provider: "kimi-code", modelId: "k3-256k", cost4: KIMI_K3, source: `derived: K3 with a fixed 256K ceiling; ${KIMI_K3_PRICING}`, verifiedAt: "2026-10-24", status: "verified-derived" }, + { provider: "kimi-code", modelId: "k3.1-pro", cost4: KIMI_K31_PRO, source: KIMI_K31_PRICING, verifiedAt: "2026-10-24", status: "verified-derived" }, + { provider: "kimi-code", modelId: "k3.1-pro-highspeed", cost4: KIMI_K31_PRO_HIGHSPEED, source: KIMI_K31_PRICING, verifiedAt: "2026-10-24", status: "verified-derived" }, + { provider: "kimi-code", modelId: "kimi-k2.7-code", cost4: KIMI_K27_CODE, source: KIMI_PRICING, verifiedAt: "2026-10-24", status: "verified-derived" }, + { provider: "kimi-code", modelId: "kimi-k2.7-code-highspeed", cost4: KIMI_K27_CODE_HIGHSPEED, source: KIMI_PRICING, verifiedAt: "2026-10-24", status: "verified-derived" }, + { provider: "kimi-code", modelId: "kimi-k2.6", cost4: KIMI_K26, source: KIMI_PRICING, verifiedAt: "2026-10-24", status: "verified-derived" }, + { provider: "kimi-code", modelId: "kimi-k2.5", cost4: KIMI_K25, source: KIMI_K25_PRICING, verifiedAt: "2026-07-20", status: "verified-derived" }, // Responses reuses the kimi OAuth account and the same Coding endpoint/model seeds. - { provider: "kimi-responses", modelId: "k3", cost4: KIMI_K3, source: KIMI_K3_PRICING, verifiedAt: "2026-09-23", status: "verified-derived" }, - { provider: "kimi-responses", modelId: "k3[1m]", cost4: KIMI_K3, source: `derived: local alias sent as k3; ${KIMI_K3_PRICING}`, verifiedAt: "2026-09-23", status: "verified-derived" }, - { provider: "kimi-responses", modelId: "k3-256k", cost4: KIMI_K3, source: `derived: K3 with a fixed 256K ceiling; ${KIMI_K3_PRICING}`, verifiedAt: "2026-09-23", status: "verified-derived" }, + { provider: "kimi-responses", modelId: "k3", cost4: KIMI_K3, source: KIMI_K3_PRICING, verifiedAt: "2026-10-24", status: "verified-derived" }, + { provider: "kimi-responses", modelId: "k3[1m]", cost4: KIMI_K3, source: `derived: local alias sent as k3; ${KIMI_K3_PRICING}`, verifiedAt: "2026-10-24", status: "verified-derived" }, + { provider: "kimi-responses", modelId: "k3-256k", cost4: KIMI_K3, source: `derived: K3 with a fixed 256K ceiling; ${KIMI_K3_PRICING}`, verifiedAt: "2026-10-24", status: "verified-derived" }, + { provider: "kimi-responses", modelId: "k3.1-pro", cost4: KIMI_K31_PRO, source: KIMI_K31_PRICING, verifiedAt: "2026-10-24", status: "verified-derived" }, + { provider: "kimi-responses", modelId: "k3.1-pro-highspeed", cost4: KIMI_K31_PRO_HIGHSPEED, source: KIMI_K31_PRICING, verifiedAt: "2026-10-24", status: "verified-derived" }, // Qwen3.8-Max: vendor-published input/output rate (verified). See QWEN38_MAX_PRICING // for what that source does and does not cover. { provider: "alibaba-token-plan", modelId: "qwen3.8-max", cost4: QWEN38_MAX, source: QWEN38_MAX_PRICING, verifiedAt: "2026-08-04", status: "verified" }, diff --git a/structure/dashboard-and-usage.md b/structure/dashboard-and-usage.md index e38d47e0faf..ed3c82bb6ff 100644 --- a/structure/dashboard-and-usage.md +++ b/structure/dashboard-and-usage.md @@ -578,7 +578,7 @@ projection version changes and encodes older raw-selector rows from canonical JS still find them. CLI access-key usage is unavailable without an ISO-8601 UTC attribution timestamp, rather than a measured zero or never-used key. -Kimi Coding K3 price rows use API-reference estimates with the default five-minute cache-write rate, +Kimi Coding K3/K3.1 Pro (and `moonshot` `kimi-k3.1-pro*`) price rows are API-reference estimates at the 5-minute cache-write rate, not Code Plan billing or quota. The three Coding presets have explicit price namespaces. The retargeted `kimi-for-coding` alias stays unpriced until a verified K2.8 price or a user `modelCosts` override exists; the retired K2.7 mapping is not reused. Request, attempt and combo estimates remain diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index a81d0790042..eead725fa20 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -84,7 +84,7 @@ blank-line, line-limit, and total-limit behavior without re-encoding the growing every child stdout chunk. See [ADR-0102](decisions/ADR-0102-incremental-stream-accounting.md). Kimi Coding's Chat, API-key, and optional Responses presets consume the same model seeds in -`src/providers/registry/model-seeds.ts`, including the native `k3-256k` ID. The Responses preset +`src/providers/registry/model-seeds.ts`, including native `k3-256k` and thinking-only 1M `k3.1-pro[-highspeed]`. The Responses preset shares the `kimi` OAuth account and Coding endpoint, keeps Chat as the featured default, and enables adjacent tool-result repair on its Responses wire. Its metadata alias is generated from the registry; sharing authentication does not implicitly share a usage-price namespace. diff --git a/tests/providers/provider-registry-parity.test.ts b/tests/providers/provider-registry-parity.test.ts index cfb6ba27c1f..33da417e59d 100644 --- a/tests/providers/provider-registry-parity.test.ts +++ b/tests/providers/provider-registry-parity.test.ts @@ -1002,10 +1002,10 @@ describe("provider registry parity", () => { test("Kimi coding aliases preserve model context and capability parity", () => { // 260921: the picker AND every preset metadata list seed only ids the subscription // endpoint still serves (live /coding/v1/models: kimi-for-coding[-highspeed], k3, - // k3-256k). Seeding a retired id in a metadata list would re-arm the model-rename + // k3-256k, and since 261024 k3.1-pro[-highspeed]). Seeding a retired id in a metadata list would re-arm the model-rename // migration on every boot (#5066); saved rows still naming one are repaired by // MODEL_RENAMES instead. - const codingModels = ["k3", "k3[1m]", "k3-256k", "kimi-for-coding"]; + const codingModels = ["k3", "k3[1m]", "k3-256k", "k3.1-pro", "k3.1-pro-highspeed", "kimi-for-coding"]; const parityLists = [ "noReasoningModels", "noTemperatureModels", @@ -1025,7 +1025,8 @@ describe("provider registry parity", () => { for (const modelId of codingModels) { // 260921: kimi-for-coding (K2.8 Preview) shares the verified 1M ceiling with k3[1m]; // all other ids stay at the 256K standard window. - expect(entry?.modelContextWindows?.[modelId]).toBe(modelId === "k3[1m]" || modelId === "kimi-for-coding" ? 1_048_576 : 262_144); + // 261024: k3.1-pro[-highspeed] list context_length 1048576 live (flat 1M). + expect(entry?.modelContextWindows?.[modelId]).toBe(["k3[1m]", "k3.1-pro", "k3.1-pro-highspeed", "kimi-for-coding"].includes(modelId) ? 1_048_576 : 262_144); } for (const field of parityLists) { // Every preset list is live-id only: kimi-for-coding must be there, the retired @@ -1049,7 +1050,8 @@ describe("provider registry parity", () => { expect(entry?.noReasoningModels).not.toContain("k3[1m]"); expect(entry?.modelReasoningEfforts?.k3).toEqual(["low", "high", "max"]); expect(entry?.modelReasoningEfforts?.["k3[1m]"]).toEqual(["low", "high", "max"]); - for (const modelId of ["k3", "k3[1m]", "k3-256k"]) { + // K3.1 Pro is thinking-only and shares K3's low/high/max ladder (default max). + for (const modelId of ["k3", "k3[1m]", "k3-256k", "k3.1-pro", "k3.1-pro-highspeed"]) { expect(entry?.noReasoningModels).not.toContain(modelId); expect(entry?.modelReasoningEfforts?.[modelId]).toEqual(["low", "high", "max"]); expect(entry?.modelDefaultReasoningEfforts?.[modelId]).toBe("max"); diff --git a/tests/usage/usage-cost.test.ts b/tests/usage/usage-cost.test.ts index 49136e27f34..7ec460336d7 100644 --- a/tests/usage/usage-cost.test.ts +++ b/tests/usage/usage-cost.test.ts @@ -459,6 +459,20 @@ describe("resolveMatchedPrice", () => { expect(price?.sourceRef).toContain("default 5-minute cache-write"); expect(price?.sourceRef).toContain("not Code Plan billing/quota"); } + // K3.1 Pro: official table 2026-10-24, flat 1M pricing, default 5-minute cache write. + for (const [model, cost4] of [ + ["k3.1-pro", { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2 }], + ["k3.1-pro-highspeed", { input: 4, output: 20, cacheRead: 0.2, cacheWrite: 4 }], + ] as const) { + const price = resolveMatchedPrice(provider, model, undefined, []); + expect(price).toMatchObject({ cost4, source: "expected", status: "verified-derived", verifiedAt: "2026-10-24" }); + expect(price?.sourceRef).toContain("not Code Plan billing/quota"); + // inputTokens is the raw prompt, cached parts included: 1M uncached + 1M read + 1M written. + const usage = { inputTokens: 3_000_000, outputTokens: 1_000_000, cacheReadInputTokens: 1_000_000, cacheCreationInputTokens: 1_000_000 }; + const estimate = estimateRequestCost({ provider, model, usageStatus: "reported" as const, usage }, undefined, []); + expect(estimate?.contextTier).toBeUndefined(); + expect(estimate?.cost.total).toBeCloseTo(cost4.input + cost4.output + cost4.cacheRead + cost4.cacheWrite, 9); + } const usage = { inputTokens: 100, outputTokens: 10 }; const input = { provider, model: "kimi-for-coding", usageStatus: "reported" as const, usage }; expect(resolveMatchedPrice(provider, input.model, undefined, [])).toBeNull(); @@ -477,6 +491,16 @@ describe("resolveMatchedPrice", () => { } }); + test("Moonshot platform API prices K3.1 Pro under its kimi- ids only", () => { + expect(resolveMatchedPrice("moonshot", "kimi-k3.1-pro", undefined, [])?.cost4).toEqual({ input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2 }); + expect(resolveMatchedPrice("moonshot", "kimi-k3.1-pro-highspeed", undefined, [])?.cost4).toEqual({ input: 4, output: 20, cacheRead: 0.2, cacheWrite: 4 }); + // The coding endpoint's bare ids are not platform API ids. + expect(findExpectedPriceOverlay("moonshot", "k3.1-pro")).toBeUndefined(); + // kimi-k2.5 left the official table (discontinued 2026-08-31): its row keeps the dated snapshot. + expect(findExpectedPriceOverlay("moonshot", "kimi-k2.5")).toMatchObject({ verifiedAt: "2026-07-20" }); + expect(findExpectedPriceOverlay("moonshot", "kimi-k2.5")?.source).toContain("discontinued 2026-08-31"); + }); + test("16. shipped overlay membership: unique keys, including canonical Fable 5.1, Opus 5, Opus 5.5, Sonnet 5.5, GPT-6.1 Sol, OpenCode Go and compatibility prices", () => { expect(EXPECTED_PRICE_OVERLAYS.some(row => row.status === "unverified")).toBe(false); const keys = new Set(EXPECTED_PRICE_OVERLAYS.map(row => `${row.provider}/${row.modelId}`)); @@ -557,11 +581,15 @@ describe("resolveMatchedPrice", () => { "kimi/k3", "kimi/k3[1m]", "kimi/k3-256k", + "kimi/k3.1-pro", + "kimi/k3.1-pro-highspeed", "kimi/kimi-k2.7-code", "kimi/kimi-k2.7-code-highspeed", "kimi/kimi-k2.6", "kimi/kimi-k2.5", "moonshot/kimi-k3", + "moonshot/kimi-k3.1-pro", + "moonshot/kimi-k3.1-pro-highspeed", "moonshot/kimi-k2.7-code", "moonshot/kimi-k2.7-code-highspeed", "moonshot/kimi-k2.6", @@ -569,6 +597,8 @@ describe("resolveMatchedPrice", () => { "kimi-code/k3", "kimi-code/k3[1m]", "kimi-code/k3-256k", + "kimi-code/k3.1-pro", + "kimi-code/k3.1-pro-highspeed", "kimi-code/kimi-k2.7-code", "kimi-code/kimi-k2.7-code-highspeed", "kimi-code/kimi-k2.6", @@ -576,6 +606,8 @@ describe("resolveMatchedPrice", () => { "kimi-responses/k3", "kimi-responses/k3[1m]", "kimi-responses/k3-256k", + "kimi-responses/k3.1-pro", + "kimi-responses/k3.1-pro-highspeed", "alibaba-token-plan/qwen3.8-max", "alibaba-token-plan-intl/qwen3.8-max", // OpenCode Go — served ids with no jawcode bundle row; each reuses the From 47316142fe06171a94de02d63f1727dbed4c0382 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 24 Oct 2026 18:10:54 +0900 Subject: [PATCH 010/101] ci: run docker/keyring smoke and React Doctor on PRs only when their inputs change (#7658) * ci: run docker/keyring smoke and React Doctor on PRs only when their inputs change Every pull request the ci filter matched (about nine in ten) started docker-smoke and the ubuntu keyring-smoke leg, and every pull request started React Doctor, although React Doctor scans only gui/ and each smoke job proves a narrow contract. - ci.yml: new docker and keyring path filters in the changes job, validated with the other narrow outputs. On pull_request the two jobs also require their own filter; push (main/preview), workflow_dispatch and the nightly schedule still request both unconditionally. The aggregate ci gate mirrors the new conditions, so the ci check keeps reporting on every PR. - react-doctor.yml: pull_request trigger path-filtered to gui/** and the workflow itself; the main push trend scan is unchanged. - structure/ops/docs-and-release.md: workflow table updated. * ci: include the compatibility-version module closure in the docker filter --- .github/workflows/ci.yml | 65 +++++++++++++++++-- .github/workflows/react-doctor.yml | 7 ++ structure/ops/docs-and-release.md | 4 +- tests/ci-workflows/ci-privacy-gate.test.ts | 2 + tests/ci-workflows/ci-review-lanes.test.ts | 27 +++++++- tests/ci-workflows/ci-scope-reduction.test.ts | 35 ++++++++++ tests/ci-workflows/ci-workflows.test.ts | 9 +++ 7 files changed, 140 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 028c28ca318..63343e5f7fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -233,6 +233,10 @@ jobs: # skipping the check it selects. setup_action: ${{ steps.narrow.outputs.setup_action }} remote_helper: ${{ steps.narrow.outputs.remote_helper }} + # Pull-request scope for the two smoke jobs that used to ride the whole ci filter. + # Push, dispatch and schedule run both regardless; see the docker/keyring filters. + docker: ${{ steps.narrow.outputs.docker }} + keyring: ${{ steps.narrow.outputs.keyring }} # Whether this pull request needs the nine Windows shards: the windows path # filter below OR an added src/tests line naming a Windows marker. Computed # by scripts/ci/windows-sensitive-diff.sh, which validates its input and @@ -388,6 +392,43 @@ jobs: # real only on macOS and Windows, so its job lints and tests the crate on all three. remote_helper: - 'native/remote-workspace-helper/**' + # docker-smoke and keyring-smoke on a pull request. Both used to run on every PR the + # ci filter matched, which is nearly all of them, while each proves something narrow: + # the image builds, starts and survives recreation with its volume; the OS keyring + # binding creates, reads and deletes a secret. A pull request now runs each only when + # its own inputs move. Push to main/preview, workflow_dispatch and the nightly still + # request both unconditionally, so the release gate's push run always carries them. + # ci.yml is listed so an edit to either job verifies itself. The setup action is left to + # its own setup-action job, as the docs and structure filters leave it. + docker: + - 'Dockerfile' + - 'compose.yaml' + - '.dockerignore' + - 'docker/**' + - 'scripts/ci/docker-smoke.ts' + - 'scripts/generate-compatibility-version.ts' + - 'scripts/model-metadata.source.json' + # The image's final RUN imports version.ts to prove the generated compatibility + # manifest; these four files are its whole module closure (Bun.build metafile). + - 'src/routing/compatibility/version.ts' + - 'src/lab/digest.ts' + - 'src/lab/conformance/digest.ts' + - 'src/lab/conformance/jcs.ts' + - 'src/cli/index.ts' + - 'package.json' + - 'bun.lock' + - 'tsconfig.json' + - 'gui/package.json' + - 'gui/bun.lock' + - '.github/workflows/ci.yml' + # scripts/keyring-smoke.ts imports only src/lib/keyring-native.ts; the native binding + # itself arrives through package.json and bun.lock. + keyring: + - 'src/lib/keyring-native.ts' + - 'scripts/keyring-smoke.ts' + - 'package.json' + - 'bun.lock' + - '.github/workflows/ci.yml' # Paths whose change selects the nine Windows suite shards on a pull # request. These carry the Windows service, update, shim, intercept, # ACL and process code, and the harness every Windows shard runs. @@ -532,9 +573,11 @@ jobs: env: SETUP_ACTION: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.setup_action }} REMOTE_HELPER: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.remote_helper }} + DOCKER_SCOPE: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.docker }} + KEYRING_SCOPE: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.keyring }} run: | set -euo pipefail - for pair in "setup_action=$SETUP_ACTION" "remote_helper=$REMOTE_HELPER"; do + for pair in "setup_action=$SETUP_ACTION" "remote_helper=$REMOTE_HELPER" "docker=$DOCKER_SCOPE" "keyring=$KEYRING_SCOPE"; do case "${pair#*=}" in true|false) printf '%s\n' "$pair" >> "$GITHUB_OUTPUT" @@ -1148,7 +1191,7 @@ jobs: keyring-smoke: name: keyring ${{ matrix.name }} needs: changes - if: github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true' + if: github.event_name != 'pull_request' || (needs.changes.outputs.ci == 'true' && needs.changes.outputs.keyring == 'true') runs-on: ${{ matrix.runner }} timeout-minutes: 8 strategy: @@ -1211,7 +1254,7 @@ jobs: docker-smoke: name: docker smoke needs: changes - if: github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true' + if: github.event_name != 'pull_request' || (needs.changes.outputs.ci == 'true' && needs.changes.outputs.docker == 'true') runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -1729,6 +1772,8 @@ jobs: CHANGES_STRUCTURE: ${{ needs.changes.outputs.structure }} CHANGES_SETUP_ACTION: ${{ needs.changes.outputs.setup_action }} CHANGES_REMOTE_HELPER: ${{ needs.changes.outputs.remote_helper }} + CHANGES_DOCKER: ${{ needs.changes.outputs.docker }} + CHANGES_KEYRING: ${{ needs.changes.outputs.keyring }} CHANGES_NATIVE: ${{ needs.changes.outputs.native }} CHANGES_DESKTOP: ${{ needs.changes.outputs.desktop }} CHANGES_WINDOWS: ${{ needs.changes.outputs.windows }} @@ -1785,6 +1830,16 @@ jobs: if [ "$CHANGES_REMOTE_HELPER" = "true" ]; then remote_helper=requested fi + # docker-smoke and keyring-smoke follow the ci scope, narrowed on a pull request to + # their own filters. Mirrors their `if:` exactly, like desktop_shell above. + docker=$scoped + if [ "$EVENT_NAME" = "pull_request" ] && [ "$CHANGES_DOCKER" != "true" ]; then + docker=not-requested + fi + keyring=$scoped + if [ "$EVENT_NAME" = "pull_request" ] && [ "$CHANGES_KEYRING" != "true" ]; then + keyring=not-requested + fi # privacy-gate runs exactly where `gates` (scoped) does not, on every # pull request the ci filter declines. Deriving it from `scoped` keeps # the two scans complementary here as they are in the jobs' own @@ -1823,8 +1878,10 @@ jobs: expected_for() { case "$1" in changes|select-windows-runner) echo requested ;; - test|storage-policy|api-usage|gates|keyring-smoke|docker-smoke) + test|storage-policy|api-usage|gates) echo "$scoped" ;; + docker-smoke) echo "$docker" ;; + keyring-smoke) echo "$keyring" ;; platform-macos|widget) echo "$native" ;; desktop-shell) echo "$desktop_shell" ;; diff --git a/.github/workflows/react-doctor.yml b/.github/workflows/react-doctor.yml index d8ac13ab366..93d8a38e3df 100644 --- a/.github/workflows/react-doctor.yml +++ b/.github/workflows/react-doctor.yml @@ -14,8 +14,15 @@ name: React Doctor on: # Scans the PR's changed files relative to the merge base of the target branch. + # The scan reads only gui/ (`directory: gui` below), so a pull request that + # changes nothing there has nothing to scan: it used to start a full-history + # checkout and an npm install on every PR for an empty changed-file list. No + # branch rule requires this check, so a PR it skips is not left waiting on it. pull_request: types: [opened, synchronize, reopened, ready_for_review] + paths: + - "gui/**" + - ".github/workflows/react-doctor.yml" # Scans `main` on every push to track the health-score trend. push: branches: ["main"] diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index e9d86f0f544..9451cbfefac 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -215,7 +215,7 @@ cannot supply a write-job PR number. If no unique local current-head candidate r | Workflow | Trigger | Purpose | | --- | --- | --- | -| `.github/workflows/ci.yml` | Any `pull_request`; runtime/package `push` to `main`/`preview`; manual dispatch | A pull request verifies Linux and TypeScript: Linux runs four suite shards plus `gates` alongside the scoped docs, structure, packaging, keyring, and npm-global jobs. The `platform-macos` macOS suite, the `widget` macOS widget + Tauri app-bundle build, and the `desktop-shell` Rust toolchain build are native-gated: they run on `main`/`preview` pushes and manual dispatch, and on a pull request only when the `changes` job's native path filter selects the change. `dev` pushes start nothing; dev integration is covered by the pull-request run, while `main` and `preview` must stay push triggers because `release.yml` requires a push-event run for the exact release SHA. Windows runs nine shards only on manual dispatch with `lane=all` (or empty), not on push events. Linux runs at-most-12-file processes with a 120-second process bound; Windows uses measured six-file/480-second processes and all-file scope so its full-suite contract is unchanged. The dedicated Windows batch step sets `OCX_TEST_NO_QUEUE=1` because its sequential processes are one logical runner; each process still creates an isolated home and arms the test guards before the lock boundary. No lane retries: a test failure, a process timeout and a Bun runtime crash each fail their job on the first occurrence. Aggregate `ci` is event-aware — it derives which jobs this event requested and requires `success` from each of them and `skipped` from the rest, and on a `lane=all` dispatch it reads the run's own job list and requires nine concrete successful `windows N/9` results. `npm-global-smoke` remains GitHub-hosted because it mutates the global package prefix. Manual `lane=release-gates` keeps the ordinary native-gated jobs and selected dynamic keyring/packaging matrix legs, but skips the Windows suite and unsharded macOS control. Default `all` (or empty) still requests both diagnostics; `macos-control` requests the control without Windows suite shards. Release eligibility requires successful push-event CI on the exact release SHA; a manual lane does not authorize publishing. Changes that touch only `.github/actions/` or `native/remote-workspace-helper/` run the narrow `setup-action` and `remote-helper` jobs instead of the full matrix, and Linux shard membership follows the per-file durations in `scripts/ci/test-durations.tsv`. | +| `.github/workflows/ci.yml` | Any `pull_request`; runtime/package `push` to `main`/`preview`; manual dispatch | A pull request verifies Linux and TypeScript: Linux runs four suite shards plus `gates` alongside the scoped docs, structure, packaging, and npm-global jobs; `docker-smoke` and the ubuntu `keyring-smoke` leg run on a pull request only when the `changes` job's `docker` / `keyring` filters select it, and on every push, dispatch, and nightly run. The `platform-macos` macOS suite, the `widget` macOS widget + Tauri app-bundle build, and the `desktop-shell` Rust toolchain build are native-gated: they run on `main`/`preview` pushes and manual dispatch, and on a pull request only when the `changes` job's native path filter selects the change. `dev` pushes start nothing; dev integration is covered by the pull-request run, while `main` and `preview` must stay push triggers because `release.yml` requires a push-event run for the exact release SHA. Windows runs nine shards only on manual dispatch with `lane=all` (or empty), not on push events. Linux runs at-most-12-file processes with a 120-second process bound; Windows uses measured six-file/480-second processes and all-file scope so its full-suite contract is unchanged. The dedicated Windows batch step sets `OCX_TEST_NO_QUEUE=1` because its sequential processes are one logical runner; each process still creates an isolated home and arms the test guards before the lock boundary. No lane retries: a test failure, a process timeout and a Bun runtime crash each fail their job on the first occurrence. Aggregate `ci` is event-aware — it derives which jobs this event requested and requires `success` from each of them and `skipped` from the rest, and on a `lane=all` dispatch it reads the run's own job list and requires nine concrete successful `windows N/9` results. `npm-global-smoke` remains GitHub-hosted because it mutates the global package prefix. Manual `lane=release-gates` keeps the ordinary native-gated jobs and selected dynamic keyring/packaging matrix legs, but skips the Windows suite and unsharded macOS control. Default `all` (or empty) still requests both diagnostics; `macos-control` requests the control without Windows suite shards. Release eligibility requires successful push-event CI on the exact release SHA; a manual lane does not authorize publishing. Changes that touch only `.github/actions/` or `native/remote-workspace-helper/` run the narrow `setup-action` and `remote-helper` jobs instead of the full matrix, and Linux shard membership follows the per-file durations in `scripts/ci/test-durations.tsv`. | | `.github/workflows/dev-version-bump.yml` | Manual dispatch with an intended version and `pre-move` or `repair` mode | Opens the reviewed pull request that moves `dev` past a release target. The default `pre-move` mode runs before promotion and publication; explicit `repair` mode retains the post-publish catch-up path. It is neither called by `release.yml` nor triggered by publication. | | `.github/workflows/release.yml` | Manual dispatch only | npm publish/dry-run workflow. The `preflight` job checks channel, version sources, tag, GitHub release, npm, global tag ordering and the `dev` pre-move before any packaging job starts. The publish job repeats those checks, requires a successful push-event Cross-platform CI run for the exact `GITHUB_SHA` (a pull-request run does not qualify), requires `dev` to outrank the target, then checks the target against the freshly fetched global tag set before publish or dry-run. After a real publish, `release-outcomes` reports the public GitHub release, the npm version read-back and the npm dist-tag as separate rows. | | `.github/workflows/deploy-docs.yml` | `push` to `main` touching `docs-site/**` or the workflow, or manual dispatch | Build and publish the Astro/Starlight docs site to GitHub Pages. This is the deploy path; the pull-request build gate is the `docs-site-build` job in `ci.yml`. | @@ -226,7 +226,7 @@ cannot supply a write-job PR number. If no unique local current-head candidate r | `.github/workflows/issue-quality-tests.yml` | `pull_request` and `push` to `main`/`preview` filtered on the issue/PR automation scripts, templates, and their workflows | Tests the issue and PR automation scripts themselves, so the gates cannot rot silently. | | `.github/workflows/issue-triage.yml` | `issues` (opened) | Duplicate detection and triage labeling for new issues. | | `.github/workflows/pr-labeler.yml` | `pull_request_target` (opened, edited, synchronize, labeled, unlabeled) | Type and path labeling plus title sync; `labeled`/`unlabeled` let a human override enqueue a fresher run in the per-PR concurrency group. | -| `.github/workflows/react-doctor.yml` | `pull_request` (opened, synchronize, reopened, ready_for_review) and `push` to `main`; no path filter | React-focused static review. Findings fail the job; write-scoped outputs stay disabled, a contract pinned by `tests/ci-workflows/ci-workflows.test.ts`. | +| `.github/workflows/react-doctor.yml` | `pull_request` (opened, synchronize, reopened, ready_for_review) touching `gui/**` or the workflow, and every `push` to `main` | React-focused static review. Findings fail the job; write-scoped outputs stay disabled, a contract pinned by `tests/ci-workflows/ci-workflows.test.ts`. | | `.github/workflows/stale-needs-info.yml` | `schedule` only (daily 06:15 UTC); deliberately no manual dispatch | Closes issues left in needs-info past the grace period. Manual dispatch is omitted so a branch-selected run cannot execute that branch's body with issue write scope. | `pull_request_target`, `issues`, and `schedule` workflows always load from the repository default branch, not from `dev`. Landing a change to one of them on `dev` does not change live behavior until it is promoted, so those files follow the promotion model rather than ordinary integration. diff --git a/tests/ci-workflows/ci-privacy-gate.test.ts b/tests/ci-workflows/ci-privacy-gate.test.ts index fbe2e019d17..c0ef44bbfe8 100644 --- a/tests/ci-workflows/ci-privacy-gate.test.ts +++ b/tests/ci-workflows/ci-privacy-gate.test.ts @@ -123,6 +123,8 @@ describe.skipIf(cannotRunAggregate)("the aggregate ci gate, executed", () => { CHANGES_STRUCTURE: "false", CHANGES_SETUP_ACTION: "false", CHANGES_REMOTE_HELPER: "false", + CHANGES_DOCKER: "false", + CHANGES_KEYRING: "false", ...scope, // needs serializes as an object per job; the gate reads .value.result. RESULTS: JSON.stringify(Object.fromEntries(Object.entries(results).map(([job, result]) => [job, { result }]))), diff --git a/tests/ci-workflows/ci-review-lanes.test.ts b/tests/ci-workflows/ci-review-lanes.test.ts index ea51831dbab..09fc9956ad2 100644 --- a/tests/ci-workflows/ci-review-lanes.test.ts +++ b/tests/ci-workflows/ci-review-lanes.test.ts @@ -166,11 +166,22 @@ describe("CI review lanes", () => { const scopedCondition = "github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true'"; // platform-macos is native-gated and pinned to the native condition above, // so it must not be read against this ci-only condition. - for (const jobName of ["test", "storage-policy", "gates", "keyring-smoke", "docker-smoke"]) { + for (const jobName of ["test", "storage-policy", "gates"]) { const job = ci.jobs?.[jobName] as { needs?: string; if?: string } | undefined; expect(`${jobName}:${job?.needs}`).toBe(`${jobName}:changes`); expect(`${jobName}:${job?.if}`).toBe(`${jobName}:${scopedCondition}`); } + // The two smoke jobs keep the ci scope and, on a pull request only, also need their + // own narrow filter. Every other event still requests them unconditionally. + for (const [jobName, output] of [["keyring-smoke", "keyring"], ["docker-smoke", "docker"]] as const) { + const job = ci.jobs?.[jobName] as { needs?: string; if?: string } | undefined; + expect(`${jobName}:${job?.needs}`).toBe(`${jobName}:changes`); + expect(`${jobName}:${job?.if}`).toBe( + `${jobName}:github.event_name != 'pull_request' || (needs.changes.outputs.ci == 'true' && needs.changes.outputs.${output} == 'true')`, + ); + expect((ci.jobs?.changes as { outputs?: Record } | undefined)?.outputs?.[output]) + .toBe(`\${{ steps.narrow.outputs.${output} }}`); + } const macosControlIf = ci.jobs?.["macos-control"] as { needs?: string; if?: string } | undefined; expect(macosControlIf?.needs).toBe("changes"); expect(macosControlIf?.if).toBe("github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' && (github.event.inputs.lane == '' || github.event.inputs.lane == 'all' || github.event.inputs.lane == 'macos-control')"); @@ -182,12 +193,12 @@ describe("CI review lanes", () => { }; // These job conditions use boolean operators and lowercase string comparisons. // Evaluate the checked-in expressions, rather than a second implementation of them. - const enabled = (job: string, event: string, lane: string, scope = "true", packaging = "true", native = "true", windows = "false") => { + const enabled = (job: string, event: string, lane: string, scope = "true", packaging = "true", native = "true", windows = "false", smoke = "true") => { const condition = ci.jobs[job]!.if ?? "true"; const evaluate = new Function("github", "needs", `return (${condition});`); return evaluate( { event_name: event, event: { inputs: { lane } } }, - { changes: { outputs: { ci: scope, packaging, native, windows } } }, + { changes: { outputs: { ci: scope, packaging, native, windows, docker: smoke, keyring: smoke } } }, ); }; for (const [event, lane, windows, control] of [ @@ -210,6 +221,14 @@ describe("CI review lanes", () => { expect(enabled(job, event, lane)).toBe(true); expect(enabled(job, event, lane, "false")).toBe(event !== "pull_request"); } + // A pull request whose diff misses the smoke filters skips both smoke jobs; any + // other event still runs them, which is what keeps them on the release push run. + for (const job of ["keyring-smoke", "docker-smoke"]) { + expect(enabled(job, event, lane, "true", "true", "true", "false", "false")).toBe(event !== "pull_request"); + } + for (const job of ["test", "gates", "storage-policy", "api-usage"]) { + expect(enabled(job, event, lane, "true", "true", "true", "false", "false")).toBe(true); + } // macOS jobs never run on a pull request; they run after merge, on push. for (const job of ["platform-macos", "widget"]) { for (const scope of ["true", "false"]) { @@ -281,6 +300,7 @@ describe("CI review lanes", () => { CHANGES_CI: "true", CHANGES_NATIVE: "true", CHANGES_PACKAGING: packaging, CHANGES_DOCS: "false", CHANGES_STRUCTURE: "false", CHANGES_SETUP_ACTION: "false", CHANGES_REMOTE_HELPER: "false", + CHANGES_DOCKER: "true", CHANGES_KEYRING: "true", }, deadlineMs: 5_000, }); @@ -394,6 +414,7 @@ describe("Windows and nightly CI contracts", () => { CHANGES_CI: "true", CHANGES_NATIVE: "true", CHANGES_PACKAGING: "true", CHANGES_DESKTOP: "false", CHANGES_DOCS: "false", CHANGES_STRUCTURE: "false", CHANGES_SETUP_ACTION: "false", CHANGES_REMOTE_HELPER: "false", + CHANGES_DOCKER: "false", CHANGES_KEYRING: "false", ...(windows === undefined ? {} : { CHANGES_WINDOWS: windows }), }; const result = spawnSync("bash", ["-c", `set -u\n${slice}\necho "WIN=$(expected_for platform-windows)"\necho "CTL=$(expected_for macos-control)"`], { diff --git a/tests/ci-workflows/ci-scope-reduction.test.ts b/tests/ci-workflows/ci-scope-reduction.test.ts index 37380ea2bb2..8c20565a493 100644 --- a/tests/ci-workflows/ci-scope-reduction.test.ts +++ b/tests/ci-workflows/ci-scope-reduction.test.ts @@ -245,6 +245,8 @@ type GateInputs = { docs: string; structure: string; lane: string; + docker?: string; + keyring?: string; }; const PR_WITHOUT_NATIVE: GateInputs = { @@ -255,6 +257,8 @@ const PR_WITHOUT_NATIVE: GateInputs = { docs: "false", structure: "false", lane: "", + docker: "true", + keyring: "true", }; function gateEnvironment(inputs: GateInputs, results: Record): Record { @@ -266,6 +270,8 @@ function gateEnvironment(inputs: GateInputs, results: Record): R CHANGES_PACKAGING: inputs.packaging, CHANGES_DOCS: inputs.docs, CHANGES_STRUCTURE: inputs.structure, + CHANGES_DOCKER: inputs.docker ?? "false", + CHANGES_KEYRING: inputs.keyring ?? "false", LANE: inputs.lane, // `needs` serializes as an object per job, and the gate reads // `.value.result` out of it. Passing a flat name-to-string map makes jq @@ -504,4 +510,33 @@ describe("the aggregate gate", () => { expect(withPackaging.expectations["npm-global-smoke"]).toBe("requested"); expect(withPackaging.expectations["keyring-smoke"]).toBe("requested"); }); + + test.skipIf(cannotRunGate)("drops the smoke jobs from a pull request that misses their filters, and only there", () => { + const narrow = { ...PR_WITHOUT_NATIVE, docker: "false", keyring: "false" }; + const { expectations } = runGate(narrow, {}); + expect(expectations["docker-smoke"]).toBe("not-requested"); + expect(expectations["keyring-smoke"]).toBe("not-requested"); + for (const name of ["test", "storage-policy", "api-usage", "gates"]) { + expect(`${name}:${expectations[name]}`).toBe(`${name}:requested`); + } + // Each filter selects only its own job. + const dockerOnly = runGate({ ...narrow, docker: "true" }, {}).expectations; + expect([dockerOnly["docker-smoke"], dockerOnly["keyring-smoke"]]).toEqual(["requested", "not-requested"]); + const keyringOnly = runGate({ ...narrow, keyring: "true" }, {}).expectations; + expect([keyringOnly["docker-smoke"], keyringOnly["keyring-smoke"]]).toEqual(["not-requested", "requested"]); + // An out-of-scope pull request stays out of scope even when a smoke filter matched. + const outOfScope = runGate({ ...narrow, ci: "false", docker: "true", keyring: "true" }, {}).expectations; + expect([outOfScope["docker-smoke"], outOfScope["keyring-smoke"]]).toEqual(["not-requested", "not-requested"]); + // Push, dispatch and schedule ignore the smoke filters: the release gate reads the push run. + for (const eventName of ["push", "workflow_dispatch", "schedule"]) { + const other = runGate({ ...narrow, eventName }, {}).expectations; + expect(`${eventName}:${other["docker-smoke"]}:${other["keyring-smoke"]}`).toBe(`${eventName}:requested:requested`); + } + // Results consistent with the narrowed expectations pass; a smoke job that ran anyway fails by name. + const consistent = Object.fromEntries(Object.keys(jobs) + .filter(name => name !== "ci") + .map(name => [name, expectations[name] === "requested" ? "success" : "skipped"])); + expect(runGate(narrow, consistent).bad).toBe(""); + expect(runGate(narrow, { ...consistent, "docker-smoke": "success" }).bad).toContain("docker-smoke"); + }); }); diff --git a/tests/ci-workflows/ci-workflows.test.ts b/tests/ci-workflows/ci-workflows.test.ts index eaea2720078..c73498ce2b2 100644 --- a/tests/ci-workflows/ci-workflows.test.ts +++ b/tests/ci-workflows/ci-workflows.test.ts @@ -5253,6 +5253,15 @@ describe("GitHub Actions hardening", () => { test("React Doctor workflow is SHA-pinned, engine-pinned, gating, and read-only", async () => { const workflow = await readText(".github/workflows/react-doctor.yml"); + // Pull requests scan only when gui/ (the scanned directory) or this workflow changes; + // the main push trend scan stays unfiltered. + const triggers = (Bun.YAML.parse(workflow) as { + on?: { pull_request?: { types?: string[]; paths?: string[] }; push?: { branches?: string[]; paths?: string[] } }; + }).on; + expect(triggers?.pull_request?.types).toEqual(["opened", "synchronize", "reopened", "ready_for_review"]); + expect(triggers?.pull_request?.paths).toEqual(["gui/**", ".github/workflows/react-doctor.yml"]); + expect(workflow).toContain("directory: gui"); + expect(triggers?.push).toEqual({ branches: ["main"] }); expect(workflow).toContain("actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8"); expect(workflow).toContain("millionco/react-doctor@01820bb4fd4d0a4aebcd8df2b2a143a098649cb2"); From 525316422e6ba219311e281cca9368125d8bea45 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 24 Oct 2026 18:18:52 +0900 Subject: [PATCH 011/101] fix(qoder): provider-only catalog, replay-envelope refusal and isolated status (#7545) Qoder: carry the replay-envelope guard, selector-status refusal and legacy catalog collision fixes onto current dev. - Assistant text passes through the vendor-scaffolding guard and the shared tool-envelope line/fence recognizer; a replayed envelope ends the turn with non-retryable vendor_scaffold_detected without publishing it. Held text is settled before tool and reasoning events, and the recognizer resets at thinking boundaries. - A contribution that raises UnrepresentableSelectorError reports unsafe / unresolvable-path for that client instead of failing the whole status collection. - An unowned legacy modelConfigs.customModels row (provider opencodex, exported model) is reported as a conflict before any write. Refs #7517 #7512 #7390 Co-authored-by: Ingwannu <186453546+Ingwannu@users.noreply.github.com> Co-authored-by: terrytan95 <10609214+terrytan95@users.noreply.github.com> --- .../src/content/docs/guides/integrations.md | 17 +- .../src/content/docs/guides/providers.md | 1 + scripts/test-layout/layout.json | 2 +- src/adapters/qoder/adapter.ts | 137 +++++++- src/adapters/qoder/scaffold-guard.ts | 5 + src/clients/config-export/qoder.ts | 59 ++-- src/integrations/merge.ts | 8 + src/integrations/mutation-plan.ts | 16 +- src/integrations/state.ts | 37 ++- src/integrations/writer.ts | 12 +- structure/clients/integrations.md | 2 + structure/clients/qoder.md | 39 ++- ...ADR-7390-qoder-legacy-catalog-collision.md | 12 + .../ADR-7512-qoder-selector-status.md | 12 + .../ADR-7517-qoder-replay-envelope.md | 12 + structure/ops/test-sandbox-cleanup.md | 2 +- structure/providers-and-adapters.md | 2 +- tests/clients/qoder-client.test.ts | 113 ++++++- tests/fixtures/test-layout-expected.json | 1 + tests/providers/qoder-adapter.test.ts | 20 +- tests/providers/qoder-scaffold-guard.test.ts | 294 ++++++++++++++++++ .../management-qoder-selector-state.test.ts | 160 ++++++++++ 22 files changed, 901 insertions(+), 62 deletions(-) create mode 100644 structure/decisions/ADR-7390-qoder-legacy-catalog-collision.md create mode 100644 structure/decisions/ADR-7512-qoder-selector-status.md create mode 100644 structure/decisions/ADR-7517-qoder-replay-envelope.md create mode 100644 tests/server/management-qoder-selector-state.test.ts diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index e931f91263f..6f22b0e9bc3 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -74,15 +74,22 @@ ocx integration client disable --client qoder ``` Qoder uses `settings.json` in `QODER_CONFIG_DIR` when that variable is set, and -`~/.qoder/settings.json` otherwise; a relative `QODER_CONFIG_DIR` is refused. Enable merges `providers.opencodex` and -OpenCodex entries in `modelConfigs.customModels`, preserving other providers, custom models, -and settings. Disable removes the recorded OpenCodex entries; restore uses the existing +`~/.qoder/settings.json` otherwise; a relative `QODER_CONFIG_DIR` is refused. Enable merges only `providers.opencodex`, preserving other providers, custom models, +and settings. Models appear once in the provider catalog; duplicating them in +`modelConfigs.customModels` causes Qoder to reject the provider. Refreshing an older managed +integration removes its recorded duplicate rows; user-edited rows remain conflicts. Disable removes the recorded OpenCodex entries; restore uses the existing snapshot workflow. The provider's default model is the first model in the sorted export. -Custom models use their catalog context window, falling back to 128000 input tokens. +If `modelConfigs.customModels` already holds an `opencodex` row for an exported model that +OpenCodex did not write, enable, refresh, overwrite and disable report a conflict and leave the +file unchanged; remove or rename that row in Qoder's settings, then try again. Rows for other +providers or for models outside the export are left alone. +Provider models use their catalog `contextWindow`, falling back to 128000 input tokens. +Restart Qoder after refreshing an existing integration to reload the provider registry. The generated API key is a non-secret placeholder: Qoder integration requires loopback access and refuses an authenticated non-loopback bind. When the dedicated unauthenticated loopback listener is enabled, exports point Qoder at that listener. `ocx sync` and catalog changes refresh an already enabled Qoder integration like the other managed clients. +Model IDs containing `,` or `]` (for example `kimi/k3[1m]`) are exported like any other model. Generated catalogs include only enabled models from each provider selection. This applies to both downloads and managed integrations, including Pi and Aside. The management model list still shows @@ -343,6 +350,8 @@ profiles** remains a separate bulk action and is not bound to one combined previ ## What to expect, honestly +If a generated managed path cannot be represented safely, that client reports an unsafe status and its preview refuses the operation. Attempts to apply, overwrite or disable return HTTP 409 without changing the configuration. Other clients remain available. + **Managed configuration files are limited to 4 MiB (4,194,304 UTF-8 bytes).** The shared integration reader refuses larger files as unsafe to read, and the writer refuses a merged configuration that exceeds the same limit with `write_failed`, leaving diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index e9bdbb56678..a8d5144e8b9 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -1330,6 +1330,7 @@ OpenCodex provides official adapter support for Qoder through the `qoder` (Globa - **Model Discovery:** `qoder --list-models` is the authoritative entitlement roster for the current PAT. The cache is bound to an irreversible fingerprint of the token, so switching accounts never reuses another account's roster. If discovery fails, the provider degrades to a stale cache and then the documented static seed. - **Token usage:** Qoder CLI reports zero token counts unless its usage switch is on, so each preset turns it on in its scoped CLI child environment: `QODER_EXPOSE_TOKEN_USAGE=true` for Global and `QODERCN_EXPOSE_TOKEN_USAGE=true` for CN, which reads only its own prefix. - **Tool Ownership:** The CLI runs single-turn `stream-json` with `--tools ""`, `--strict-mcp-config`, setting sources disabled, and session persistence disabled, so Codex keeps exclusive tool ownership. Client-declared tools are exposed through an isolated request-scoped MCP server. +- **Output guard:** If the CLI echoes OpenCodex tool-call/result replay envelopes into assistant text, the turn fails with `vendor_scaffold_detected` (502) instead of returning the envelope as a successful answer, and neither reasoning nor tool calls following the envelope are forwarded. Reasoning starts a fresh replay-text block, so earlier prose or code fences cannot hide an envelope in later text. If the CLI's turn already ended with its own error, that error is reported instead, still without the envelope. The proxy does not retry that failure automatically. Ordinary prose and closed fenced examples follow the shared envelope recognizer; an unverified overflowing fenced example is refused. Assistant text preserves Unicode characters split across CLI frames; unpaired surrogates are replaced with U+FFFD. - **Image Input:** Qoder Global accepts image input through the Qoder CLI. OpenCodex forwards supported image inputs that reach the Global adapter to the CLI without maintaining a per-model vision capability allowlist there. Image compatibility and any model-specific preprocessing or routing are handled by Qoder. OpenCodex does not use the static discovery fallback roster to classify per-model vision capability. Qoder CN keeps its existing sidecar classification, and any residual raw-image request that reaches the CN adapter is rejected as `unsupported_input_modality`. OpenCodex serializes history before the current turn, but Qoder does not persist sessions, so prior images are replayed on later turns. In observed multi-turn image conversations, replayed images can become unreliably associated or effectively ordered relative to the historical text they originally accompanied. - **PAT recovery:** For both Qoder presets, a multi-key API-key pool can switch to another PAT on a supported authentication rejection or account-level quota exhaustion, but only before response content or an event that makes replay unsafe. Recovery requires Qoder-specific classification and respects credential-hop and send budgets; an HTTP 401/429 or quota message alone is not permission to replay. Single-key configurations do not rotate. Sharing the CLI runner does not enable this API-key recovery for CodeBuddy or self-authenticated Claude CLI. - **Quota:** No public quota API is used, so totals and reset times are unavailable. Codes 110 (account daily limit) and 118 (personal Credits exhaustion) report `insufficient_quota` and permit consideration of PAT recovery. Other supported quota codes also report `insufficient_quota` but do not authorize it. Numeric error codes take precedence: an unrecognized numeric code does not fall back to quota or authentication text. Without a numeric code, only the supported account-daily message beginning “You've reached your daily usage limit for Chat”, with no team/model/shared qualifier, grants quota recovery; generic credit-limit text does not. Only quota failures eligible for PAT recovery enter key cooldown handling. When such a failure has no reset time, the failed key uses the existing default cooldown; nonrecoverable quota classifications do not create a cooldown through this path, and no next-day reset is inferred. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index e2ec2449dfd..329f5cb1306 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1200,7 +1200,7 @@ "management-inference-port.test.ts": "server", "management-google-tool-schema-policy.test.ts": "server", "management-integration-journal-delete.test.ts": "server", - "management-integration-routes.test.ts": "server", + "management-integration-routes.test.ts": "server", "management-qoder-selector-state.test.ts": "server", "management-metrics-export.test.ts": "server", "management-model-roster-gather-race.test.ts": "server", "management-model-roster.test.ts": "server", diff --git a/src/adapters/qoder/adapter.ts b/src/adapters/qoder/adapter.ts index e0254fbb7bd..188c29dfb2b 100644 --- a/src/adapters/qoder/adapter.ts +++ b/src/adapters/qoder/adapter.ts @@ -7,6 +7,7 @@ import { buildCodingAgentToolCatalog, CodingAgentToolCatalogError } from "../cod import { baseScopedEnv, runCodingAgentTurn, type CodingAgentDeps, type CodingAgentToolBridgeInput, type CodingAgentFailure } from "../coding-agent/turn"; import { QODER_PROFILES, resolveQoderProfile, type QoderProfile } from "./profiles"; import { QoderScaffoldFilter, QODER_SCAFFOLD_ERROR_CODE, qoderScaffoldErrorMessage } from "./scaffold-guard"; +import { ToolEnvelopeEchoFilter } from "../../lib/tool-envelope-echo-filter"; export type QoderAdapterDeps = CodingAgentDeps; @@ -50,6 +51,8 @@ function classifyQoderFailure(failure: CodingAgentFailure): Extract { // Each build reads its own brand prefix with no fallback: qodercn ignores QODER_EXPOSE_TOKEN_USAGE. @@ -79,6 +82,8 @@ export function buildQoderArgs(parsed: OcxParsedRequest, provider: OcxProviderCo /** * Wrap the turn's outbound channel with the scaffolding guard (#4190). + * Vendor-cleaned assistant text also uses the shared replay-envelope recognizer + * (#7517), so internal history scaffolding cannot become a successful answer. * * The vendor CLI can put its own agent layer into the text channel despite built-in tools * being disabled, and the shared stream-json parser forwards a text delta @@ -93,11 +98,18 @@ export function buildQoderArgs(parsed: OcxParsedRequest, provider: OcxProviderCo export function guardQoderScaffolding(emit: (event: AdapterEvent) => void): (event: AdapterEvent) => void { const textFilter = new QoderScaffoldFilter(); const thinkingFilter = new QoderScaffoldFilter(); + let replayFilter = new ToolEnvelopeEchoFilter(); let closed = false; + let boundaries: { offset: number; event: AdapterEvent }[] = []; + let boundaryChars = 0; + let textCarry = ""; const refuse = (reason: string): void => { if (closed) return; closed = true; + boundaries = []; + boundaryChars = 0; + textCarry = ""; emit({ type: "error", message: qoderScaffoldErrorMessage(reason), @@ -110,26 +122,134 @@ export function guardQoderScaffolding(emit: (event: AdapterEvent) => void): (eve }); }; + const forwardText = (text: string, refuseOnMatch = true): void => { + const safe = replayFilter.feed(text); + // An overflowing fenced hold can release an unverified marker. Refuse it + // before publication rather than leaking the envelope under a memory cap. + if (replayFilter.unverifiedMarker) { + if (refuseOnMatch) refuse("replayed tool envelope"); + return; + } + if (safe) emit({ type: "text_delta", text: safe }); + if (replayFilter.matched && refuseOnMatch) refuse("replayed tool envelope"); + }; + + /* + * Any other event ends the text block before it. Settle text the replay filter + * still holds so the event cannot overtake it, and refuse before publishing the + * event when that text completes an envelope. Heartbeats carry no content and + * must not split a marker. The next text block starts at a line start, so an + * envelope opening it is recognized even when the previous block ended mid-line. + */ + const settleText = (refuseOnMatch = true): boolean => { + const tail = replayFilter.finish(); + if (replayFilter.matched || replayFilter.unverifiedMarker) { + if (refuseOnMatch) refuse("replayed tool envelope"); + return false; + } + if (tail) emit({ type: "text_delta", text: tail }); + replayFilter = new ToolEnvelopeEchoFilter(); + return true; + }; + + const forwardEvent = (event: AdapterEvent): void => { + // A vendor prefix can still turn a replay marker into ordinary prose. Keep + // its event cuts until the vendor filter resolves it instead of flushing it. + const offset = textFilter.pendingTextLength + textCarry.length; + if (offset > 0) { + const size = event.type === "thinking_delta" ? event.thinking.length : JSON.stringify(event).length; + if (boundaryChars + size > MAX_PENDING_BOUNDARY_CHARS + || boundaries.length >= MAX_PENDING_BOUNDARY_EVENTS) { + refuse("an unresolved text boundary exceeding its buffer limit"); + return; + } + boundaryChars += size; + const last = boundaries.at(-1); + if (last?.offset === offset + && last.event.type === "thinking_delta" && event.type === "thinking_delta") { + last.event = { type: "thinking_delta", thinking: last.event.thinking + event.thinking }; + } else boundaries.push({ offset, event }); + return; + } + if (settleText() && (event.type !== "thinking_delta" || event.thinking)) emit(event); + }; + + const drainBoundaries = (text: string, refuseOnMatch = true): void => { + const queued = boundaries; + boundaries = []; + boundaryChars = 0; + let at = 0; + for (const boundary of queued) { + // A suppressed prefix contributes no text; surviving prefixes keep their offsets. + let end = text ? Math.min(text.length, Math.max(at, boundary.offset)) : 0; + const before = text.charCodeAt(end - 1), after = text.charCodeAt(end); + // A scalar completed by a later input belongs before events between its halves. + if (before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff) end++; + forwardText(text.slice(at, end), refuseOnMatch); + if (closed || !settleText(refuseOnMatch)) return; + if (boundary.event.type !== "thinking_delta" || boundary.event.thinking) emit(boundary.event); + at = end; + } + if (!closed) forwardText(text.slice(at), refuseOnMatch); + }; + + const prepareText = (text: string, terminal = false): string => { + let complete = textCarry + text; + textCarry = ""; + const last = complete.charCodeAt(complete.length - 1); + if (!terminal && last >= 0xd800 && last <= 0xdbff) { + textCarry = complete.slice(-1); + complete = complete.slice(0, -1); + } + return complete.toWellFormed(); + }; + + const pushText = (text: string, refuseOnMatch = true): string | null => { + let at = 0; + while (boundaries.length && at < text.length) { + const width = text.codePointAt(at)! > 0xffff ? 2 : 1; + const cleaned = textFilter.push(text.slice(at, at + width)); + at += width; + if (cleaned.fail) { + if (refuseOnMatch) refuse(cleaned.fail); + return cleaned.fail; + } + // A pending vendor suffix is one marker prefix, starting with its only + // '<'. Its first release resolves the entire suffix and its saved cuts. + if (cleaned.text || textFilter.pendingTextLength === 0) drainBoundaries(cleaned.text, refuseOnMatch); + if (closed) return null; + } + const cleaned = textFilter.push(text.slice(at)); + if (cleaned.text) forwardText(cleaned.text, refuseOnMatch); + if (cleaned.fail && refuseOnMatch) refuse(cleaned.fail); + return cleaned.fail; + }; + return (event: AdapterEvent): void => { if (closed) return; if (event.type === "text_delta") { - const cleaned = textFilter.push(event.text); - if (cleaned.text) emit({ ...event, text: cleaned.text }); - if (cleaned.fail) refuse(cleaned.fail); + if (event.text) pushText(prepareText(event.text)); return; } if (event.type === "thinking_delta") { const cleaned = thinkingFilter.push(event.thinking); - if (cleaned.text) emit({ ...event, thinking: cleaned.text }); + forwardEvent({ ...event, thinking: cleaned.text }); if (cleaned.fail) refuse(cleaned.fail); return; } if (event.type === "done" || event.type === "error" || event.type === "incomplete") { + const carryFailure = textCarry ? pushText(prepareText("", true), false) : null; const tail = textFilter.flush(); const reasoning = thinkingFilter.flush(); - if (tail.text) emit({ type: "text_delta", text: tail.text }); - if (reasoning.text) emit({ type: "thinking_delta", thinking: reasoning.text }); - const fail = tail.fail ?? reasoning.fail; + drainBoundaries(tail.text, false); + if (closed) return; + const replayTail = replayFilter.finish(); + if (replayTail && !replayFilter.unverifiedMarker) emit({ type: "text_delta", text: replayTail }); + if (reasoning.text && !replayFilter.matched && !replayFilter.unverifiedMarker) { + emit({ type: "thinking_delta", thinking: reasoning.text }); + } + const fail = carryFailure ?? tail.fail ?? reasoning.fail + ?? (replayFilter.matched || replayFilter.unverifiedMarker ? "replayed tool envelope" : null); // A vendor error already carries the better explanation for why the turn ended; // only a terminal that claims success is replaced. if (fail && event.type !== "error") { @@ -140,7 +260,8 @@ export function guardQoderScaffolding(emit: (event: AdapterEvent) => void): (eve emit(event); return; } - emit(event); + if (event.type === "heartbeat") emit(event); + else forwardEvent(event); }; } diff --git a/src/adapters/qoder/scaffold-guard.ts b/src/adapters/qoder/scaffold-guard.ts index 8ace7c93c72..a24f83d13d2 100644 --- a/src/adapters/qoder/scaffold-guard.ts +++ b/src/adapters/qoder/scaffold-guard.ts @@ -138,6 +138,11 @@ export class QoderScaffoldFilter { /** Open reminder blocks; only the closer that unwinds the last one ends suppression. */ private suppressDepth = 0; + /** Unpublished marker-prefix characters; excludes suppressed block content. */ + get pendingTextLength(): number { + return this.pending.length; + } + push(chunk: string): ScaffoldFilterResult { if (this.failed || !chunk) return { text: "", fail: null }; let cleared = ""; diff --git a/src/clients/config-export/qoder.ts b/src/clients/config-export/qoder.ts index 694c264a18d..aaccfe775a3 100644 --- a/src/clients/config-export/qoder.ts +++ b/src/clients/config-export/qoder.ts @@ -1,7 +1,6 @@ import { homedir } from "node:os"; import { isAbsolute, join } from "node:path"; import { isLoopbackHostname, shouldInjectApiAuthHeader } from "../../codex/loopback-target"; -import { formatSelectorConjunction } from "../../integrations/merge"; import { ClientPathError } from "../config-export"; import type { ExportContext, ManagedContribution, OpencodeLaunchEnv } from "./contracts"; import { LOOPBACK_API_KEY_PLACEHOLDER, OPENCODE_PROVIDER_ID } from "./constants"; @@ -30,7 +29,7 @@ export function qoderConfigPath(env: OpencodeLaunchEnv = process.env, home: stri return join(qoderHomeDir(env, home), "settings.json"); } -/** Qoder IDE and qodercli share this provider and custom-model catalog. */ +/** Qoder IDE and qodercli share this custom-provider catalog. */ export function buildQoderClientConfig(ctx: ExportContext) { if (shouldInjectApiAuthHeader(ctx.config) || !isLoopbackHostname(new URL(ctx.baseUrl).hostname)) { throw new Error("Qoder's placeholder API key requires a loopback-only listener."); @@ -49,26 +48,21 @@ function qoderDocument(ctx: ExportContext) { baseUrl: ctx.baseUrl, apiKey: LOOPBACK_API_KEY_PLACEHOLDER, ...(models[0] ? { defaultModel: models[0].namespaced } : {}), - models: models.map(model => ({ model: model.namespaced })), + // Register each key once; legacy customModels entries shadow this provider. + models: models.map(model => ({ + model: model.namespaced, + displayName: model.namespaced, + contextWindow: authoritativeContextWindow(model.contextWindow) ?? 128000, + })), }, }, - modelConfigs: { - customModels: models.map(model => ({ - provider: OPENCODE_PROVIDER_ID, - apiKey: LOOPBACK_API_KEY_PLACEHOLDER, - model: model.namespaced, - baseURL: ctx.baseUrl, - displayName: model.namespaced, - maxInputTokens: authoritativeContextWindow(model.contextWindow) ?? 128000, - })), - }, }; } export type QoderGeneratedConfig = ReturnType; export function summarizeQoder(document: unknown): { modelCount: number; modelsWithoutLimits: number } { - const models = (document as QoderGeneratedConfig | undefined)?.modelConfigs?.customModels ?? []; + const models = (document as QoderGeneratedConfig | undefined)?.providers?.[OPENCODE_PROVIDER_ID]?.models ?? []; return { modelCount: models.length, modelsWithoutLimits: 0 }; } @@ -80,14 +74,35 @@ export function buildQoderContribution(ctx: ExportContext): ManagedContribution clientId: "qoder", fragments: [ { path: ["providers", OPENCODE_PROVIDER_ID], value: doc.providers[OPENCODE_PROVIDER_ID] }, - ...doc.modelConfigs.customModels.map(model => { - const selector = formatSelectorConjunction([ - { field: "provider", value: OPENCODE_PROVIDER_ID }, - { field: "model", value: model.model }, - ]); - if (selector === null) throw new Error("Qoder model ID cannot be represented by a managed array selector."); - return { path: ["modelConfigs", "customModels", selector], value: model }; - }), ], }; } + +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Does an unowned legacy `modelConfigs.customModels` row name a model this + * contribution's provider catalog exports? Qoder rejects the whole provider + * when one key appears in both catalogs, so keeping such a row while adding + * `providers.opencodex` recreates the failure the provider-only export fixes, + * and deleting it would discard configuration we never wrote. `owned` holds + * the rows an ownership record still names; refresh removes those itself. + */ +export function qoderLegacyCatalogCollision( + doc: unknown, + contribution: ManagedContribution, + owned: ReadonlySet, +): boolean { + const provider = contribution.fragments.find(fragment => fragment.path.length === 2 + && fragment.path[0] === "providers" && fragment.path[1] === OPENCODE_PROVIDER_ID)?.value; + const exported = new Set(isPlainRecord(provider) && Array.isArray(provider.models) + ? provider.models.flatMap(model => isPlainRecord(model) && typeof model.model === "string" ? [model.model] : []) + : []); + const modelConfigs = isPlainRecord(doc) ? doc.modelConfigs : undefined; + const rows = isPlainRecord(modelConfigs) ? modelConfigs.customModels : undefined; + if (exported.size === 0 || !Array.isArray(rows)) return false; + return rows.some(row => isPlainRecord(row) && row.provider === OPENCODE_PROVIDER_ID + && typeof row.model === "string" && exported.has(row.model) && !owned.has(row)); +} diff --git a/src/integrations/merge.ts b/src/integrations/merge.ts index 73eefe41468..4cb7bfab5b9 100644 --- a/src/integrations/merge.ts +++ b/src/integrations/merge.ts @@ -62,6 +62,14 @@ export class InvalidSelectorError extends Error { } } +/** A generated contribution cannot name its exact owned array element. */ +export class UnrepresentableSelectorError extends Error { + constructor(message: string) { + super(message); + this.name = "UnrepresentableSelectorError"; + } +} + function validConjunctionCriteria(criteria: readonly SelectorCriterion[]): boolean { return criteria.length >= 2 && criteria.every(criterion => ( SELECTOR_FIELD.test(criterion.field) diff --git a/src/integrations/mutation-plan.ts b/src/integrations/mutation-plan.ts index 6a287fe9b78..e6a08ad94d7 100644 --- a/src/integrations/mutation-plan.ts +++ b/src/integrations/mutation-plan.ts @@ -38,9 +38,9 @@ import { } from "./registry"; import { declaredIntegrationTarget, resolveIntegrationTarget, type IneffectiveWrite, type IneffectiveWriteReason, type IntegrationTarget } from "./target"; import { shouldInjectApiAuthHeader } from "../codex/inject"; -import { buildIntegrationContribution, classifyIntegration, exportContextOf, readPath, type IntegrationState, type StateReason } from "./state"; +import { buildIntegrationContribution, classifyIntegration, exportContextOf, qoderLegacyCatalogConflict, readPath, type IntegrationState, type StateReason } from "./state"; import { inspectKiloCandidates } from "./kilo-candidates"; -import { InvalidSelectorError } from "./merge"; +import { InvalidSelectorError, UnrepresentableSelectorError } from "./merge"; import { createIntegrationStateStore, type IntegrationStateStore } from "./store"; import type { OcxConfig } from "../types"; import { matchesOperationResult, type JournalEntry } from "./journal"; @@ -378,7 +378,9 @@ function applyOutcome(input: PlanInput): PlanOutcome { */ if (input.ineffectiveWrite !== null) return deny("superseded_store"); // Overwrite exists precisely to proceed through a conflict the operator has been shown. - if (input.classified.state === "conflict" && input.operation !== "overwrite") return deny("conflict"); + // A Qoder legacy-catalog collision is not on a path overwrite replaces, so it still refuses. + if (input.classified.state === "conflict" && (input.operation !== "overwrite" + || qoderLegacyCatalogConflict(input.parsed, input.record, input.contribution))) return deny("conflict"); if (input.classified.state === "unsafe") return deny("unsafe"); if (input.classified.state === "current") return NOOP; return CHANGE; @@ -1002,7 +1004,13 @@ export function observeIntegration( const record = stored && stored.clientId === clientId && stored.configPath === configPath ? stored : null; - const contribution = buildIntegrationContribution(input, effective, parsed, record); + let contribution: ManagedContribution; + try { + contribution = buildIntegrationContribution(input, effective, parsed, record); + } catch (error) { + if (!(error instanceof UnrepresentableSelectorError)) throw error; + return { failed: observationFailure("unsafe", "unsafe", error.message) } as const; + } if (clientId === "droid" && input.models.length > 0 && contribution.fragments.length === 0 && !record) { return { failed: observationFailure("unsafe", "unsafe", "Factory Droid has no addressable models in the selected catalog") } as const; } diff --git a/src/integrations/state.ts b/src/integrations/state.ts index a8c0ef60596..e5b97cb7bff 100644 --- a/src/integrations/state.ts +++ b/src/integrations/state.ts @@ -11,6 +11,7 @@ import { createClineIO } from "./cline-io"; import { parseClineDocument } from "./cline-document"; import { ClientPathError, EXPORT_CLIENTS, opencodeProxyBaseUrl, droidDefaultsFromOwnedRows, type ExportModel, type ManagedContribution } from "../clients/config-export"; +import { qoderLegacyCatalogCollision } from "../clients/config-export/qoder"; import type { ConfigFormat } from "../clients/config-export"; import type { OcxConfig } from "../types"; import { PARSE_FAILED, loadTarget, parseConfig, type IntegrationIO } from "./config-io"; @@ -18,6 +19,7 @@ import { SNAPSHOT_RETENTION } from "./journal"; import { AmbiguousSelectorError, InvalidSelectorError, + UnrepresentableSelectorError, parseSegment, readPath, selectIndex, @@ -122,6 +124,24 @@ export function hasOurFragments(doc: unknown, contribution: ManagedContribution) return contribution.fragments.some(fragment => readPath(doc, fragment.path) !== undefined); } +/** + * A hand-written Qoder legacy row for one of our models makes Qoder reject the + * provider we would add. Rows a record still names are ours for refresh to + * drop. Overwrite cannot resolve this either, because the row is not on a path + * we write, so the writer and planner ask the same question before overwriting. + */ +export function qoderLegacyCatalogConflict( + parsed: unknown, + record: OwnershipRecord | null, + contribution: ManagedContribution | null, +): boolean { + if (contribution?.clientId !== "qoder") return false; + const owned = new Set((record?.fragmentPaths ?? []) + .filter(path => Array.isArray(path) && path.every(key => typeof key === "string")) + .map(path => readPath(parsed, path))); + return qoderLegacyCatalogCollision(parsed, contribution, owned); +} + /** * A container on one of our fragment paths that exists but is NOT an object. * @@ -360,6 +380,10 @@ export function classifyIntegration(input: { } throw error; } + // Before `absent`: none of our paths is occupied, yet Qoder would reject the provider. + if (qoderLegacyCatalogConflict(input.parsed, input.record, input.contribution)) { + return { state: "conflict", reason: "unowned-key" }; + } // A catalog can shrink to zero while the record still owns earlier rows. // Presence for disable must include those recorded paths, independent of the // current export roster; the ownership fingerprint is checked below. @@ -678,7 +702,18 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS const parsed = input.clientId === "cline" ? parseClineDocument(loaded.before) : parseConfig(loaded.before, effective.format, EXPORT_CLIENTS[input.clientId].jsonc ? { jsonc: true } : undefined); - const contribution = buildIntegrationContribution(input, effective, parsed, record); + let contribution: ManagedContribution; + try { + contribution = buildIntegrationContribution(input, effective, parsed, record); + } catch (error) { + // One catalog row can make this client's ownership unaddressable. Keep its + // refusal visible without hiding every other client in a collection read. + if (!(error instanceof UnrepresentableSelectorError)) throw error; + return { clientId: input.clientId, state: "unsafe", installed, configPath, + reason: "unresolvable-path", + ...(record?.configPath === configPath ? { appliedAt: record.appliedAt, lastOpId: record.opId } : {}), + ...retention }; + } if (input.clientId === "droid" && input.models.length > 0 && contribution.fragments.length === 0 && (!record || record.configPath !== configPath)) { return { clientId: input.clientId, state: "unsafe", installed, configPath, diff --git a/src/integrations/writer.ts b/src/integrations/writer.ts index aece1067fb8..8989c665586 100644 --- a/src/integrations/writer.ts +++ b/src/integrations/writer.ts @@ -44,7 +44,7 @@ import { type IntegrationClientId, } from "./registry"; import { declaredIntegrationTarget } from "./target"; -import { buildIntegrationContribution, exportContextOf } from "./state"; +import { buildIntegrationContribution, exportContextOf, qoderLegacyCatalogConflict } from "./state"; import type { IntegrationState } from "./state"; import { serializeDocument, UnserializableValueError } from "./serialize"; import { ClientPathError } from "../clients/config-export"; @@ -317,6 +317,10 @@ function applyOrRefreshIntegration( : `${clientId} now reads its providers from ${readsFrom}, whose schema opencodex does not recognise, so writing ${configPath} would change nothing it loads. ${fallback}`); } if (classified.state === "conflict") { + if (qoderLegacyCatalogConflict(parsed, record, contribution)) { + return refuse(clientId, "conflict", "conflict", + `${configPath} lists an opencodex model in modelConfigs.customModels that opencodex did not write; Qoder rejects the opencodex provider while that row exists, so remove or rename it first`); + } if (conflictPolicy === "refuse") { return refuse(clientId, "conflict", "conflict", classified.reason === "foreign-edit" @@ -545,7 +549,7 @@ export function refreshIntegration(input: IntegrationWriteInput): WriteOutcome { export function disableIntegration(input: IntegrationWriteInput): WriteOutcome { const pre = preflight(input, "disable"); if (pre.failed) return pre.failed; - const { store, io, clientId, spec, target, configPath, before, parsed, record, classified } = pre; + const { store, io, clientId, spec, target, configPath, before, parsed, record, classified, contribution } = pre; if (classified.state === "absent") { return { ok: true, changed: false, state: "absent", clientId, message: "not applied" }; @@ -554,7 +558,9 @@ export function disableIntegration(input: IntegrationWriteInput): WriteOutcome { return refuse(clientId, "conflict", "conflict", classified.reason === "foreign-edit" ? `${configPath} changed after opencodex wrote it; disabling would discard that edit` - : `${configPath} contains an opencodex block we did not write`); + : qoderLegacyCatalogConflict(parsed, record, contribution) + ? `${configPath} lists an opencodex model in modelConfigs.customModels that opencodex did not write; remove or rename that row, then disable again` + : `${configPath} contains an opencodex block we did not write`); } /* * `unsafe` reaches here the same way it reaches apply, and the code below diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md index 81d56c9efaf..99f5f0e07f9 100644 --- a/structure/clients/integrations.md +++ b/structure/clients/integrations.md @@ -125,6 +125,8 @@ client registry + export context -> ownership record + operation journal ``` +A generated contribution that raises `UnrepresentableSelectorError` produces an `unsafe` / `unresolvable-path` status for that client. The shared reader and mutation observer catch only that typed refusal: preview returns an unsafe plan with `canApply: false`, and mutations return the existing unsafe refusal (HTTP 409). Other clients in the status collection remain visible, and unexpected construction errors still propagate. + Status and mutation must use the same classifier. A special case added only to a status endpoint would be misleading because refresh or disable could still reject the same file; a special case added only to a writer would let a mutation bypass the state users saw. diff --git a/structure/clients/qoder.md b/structure/clients/qoder.md index 7ae4ef16732..f84f9501f58 100644 --- a/structure/clients/qoder.md +++ b/structure/clients/qoder.md @@ -7,11 +7,28 @@ Qoder follows the shared [client-integration ownership contract](integrations.md Qoder IDE / qodercli uses JSON at `settings.json` under `QODER_CONFIG_DIR`, default `~/.qoder`; `qoderHomeDir` resolves that directory once for export, writing and detection, and refuses a relative override. The Qoder exporter in -`src/clients/config-export.ts` writes `providers.opencodex` and one -`modelConfigs.customModels` entry per model. Array ownership selects both `provider` and -`model`, preserving same-named models belonging to other providers. Apply, refresh and disable -use the existing recorded-fragment writer; restore uses its snapshots. Model IDs that cannot -be represented by the shared selector grammar refuse managed contribution construction. +`src/clients/config-export/qoder.ts` writes only `providers.opencodex`, with each model's +`displayName` and `contextWindow` in its `models` array. It does not duplicate those keys in +`modelConfigs.customModels`: Qoder rejects the provider when the legacy catalog already +contains its keys. Apply, refresh and disable use the existing recorded-fragment writer; +restore uses its snapshots. On refresh, older ownership records remove their previously +owned custom-model rows before merging the provider-only contribution. Foreign rows remain +untouched, and edits to previously owned rows remain conflicts. The legacy selector template +in `src/integrations/mutation-plan.ts` stays available for previewing that removal. +An unowned `modelConfigs.customModels` row with `provider: "opencodex"` and a model the +provider catalog also exports would recreate that rejection, so `classifyIntegration` reports +it as a `conflict` / `unowned-key` before any write; `qoderLegacyCatalogCollision` decides +which rows collide, and rows an ownership record still names are left to the refresh above. +Rows for other providers or other models stay untouched and never conflict. +Because the provider catalog needs no array selector, model IDs containing `,` or `]` are +ordinary Qoder models. The shared reader still reports any contribution that raises +`UnrepresentableSelectorError` as `unsafe` / `unresolvable-path`; preview returns `canApply: false` +and mutations refuse with HTTP 409, so a later selector-bearing fragment cannot fail the whole status collection. + +> Decision record: [ADR-7512](../decisions/ADR-7512-qoder-selector-status.md) + +> Decision record: [ADR-7390](../decisions/ADR-7390-qoder-legacy-catalog-collision.md) + The default model is the first normalized model; an empty catalog omits the default. Input limits use authoritative context metadata or Qoder's requested 128000 fallback. Exports reject non-loopback placeholder use; the writer's existing admission gate protects @@ -21,3 +38,15 @@ unauthenticated loopback listener decide the exported URL. Owned-catalog refresh `/api/sync` and catalog refresh includes Qoder. CLI help, Dashboard export rows, integration tabs and preview-path validation register the same Qoder client; the managed path templates live in `src/integrations/mutation-plan.ts` and the builder in `src/clients/config-export/qoder.ts`. + +## Qoder assistant-text replay guard + +Qoder's assistant text passes through the vendor-scaffolding guard and then the existing `src/lib/tool-envelope-echo-filter.ts` line/fence recognizer. +A replayed tool envelope or an unverified marker released by its bounded fence hold terminates with non-retryable `vendor_scaffold_detected` (502), without publishing that envelope or a later successful terminal; when the marker is recognized only while flushing before an upstream `error` terminal, the envelope is still withheld and that error is forwarded unchanged. +Whole-line result/error markers and tool-call line prefixes are recognized across deltas; ordinary marker prose and closed fenced examples keep the shared recognizer's semantics. +Thinking retains the existing vendor-scaffolding guard. Every non-heartbeat event, including reasoning, ends the current replay-text block. +Held text is settled before reasoning or tool events, which are withheld if that text completes an envelope; the next text block starts with fresh line and fence state. +Pending vendor signatures remain guarded across reasoning boundaries. The adapter records event offsets behind an ambiguous vendor prefix until it is released or suppressed; the queue is limited to 64 Ki characters and 1024 events, and overflow refuses the turn without publishing the held content. +Assistant text retains one trailing high surrogate across nonempty text inputs, preserving pairs split between JSONL frames. A completed scalar precedes events received between its halves; deferred cuts move past the full pair. Heartbeats and empty text do not settle it. Unpaired surrogates become U+FFFD, including a remaining carry at termination, without replacing an upstream error. The carry shares the existing bounded event queue and is cleared on closure. + +> Decision record: [ADR-7517](../decisions/ADR-7517-qoder-replay-envelope.md) diff --git a/structure/decisions/ADR-7390-qoder-legacy-catalog-collision.md b/structure/decisions/ADR-7390-qoder-legacy-catalog-collision.md new file mode 100644 index 00000000000..5a51011d1f1 --- /dev/null +++ b/structure/decisions/ADR-7390-qoder-legacy-catalog-collision.md @@ -0,0 +1,12 @@ +# ADR-7390 — decision recorded under "Qoder settings ownership" + +- Contract owner: [clients/qoder.md](../clients/qoder.md) + +## Decision record + +- 목적과 의도: Stop Qoder from rejecting the opencodex provider because one model key appears in both `providers.opencodex.models` and `modelConfigs.customModels`. +- 기존 구현 및 제약 조건: The exporter wrote both catalogs, using provider-and-model array selectors for the legacy rows. Qoder treats the duplicate key as a provider conflict. Rows written by users or older manual exports carry no ownership record. +- 검토한 주요 대안: Keep writing both catalogs; write only the provider catalog and keep every unowned legacy row; delete colliding legacy rows during apply; write only the provider catalog and classify an unowned colliding row as a conflict before any write. +- 선택한 방식: Export only `providers.opencodex`. Refresh removes legacy rows a record still owns. An unowned `provider: "opencodex"` row naming an exported model makes the shared classifier return `conflict` / `unowned-key`, and the writer and preview planner also refuse `overwrite` for it. +- 다른 대안 대신 이 방식을 선택한 이유: Keeping the colliding row recreates the rejected configuration while reporting success, and deleting it removes configuration opencodex never wrote. The shared classifier already decides status, preview and every write, so all three agree. +- 장점, 단점 및 영향: Model IDs no longer need array selectors, which also removes the Qoder trigger for ADR-7512. A user with a hand-written duplicate row must remove or rename it before enable, refresh, overwrite or disable proceeds, because `overwrite` replaces only paths opencodex writes and that row is not one of them. diff --git a/structure/decisions/ADR-7512-qoder-selector-status.md b/structure/decisions/ADR-7512-qoder-selector-status.md new file mode 100644 index 00000000000..f18c509c460 --- /dev/null +++ b/structure/decisions/ADR-7512-qoder-selector-status.md @@ -0,0 +1,12 @@ +# ADR-7512 — decision recorded under "Qoder settings ownership" + +- Contract owner: [clients/qoder.md](../clients/qoder.md) + +## Decision record + +- 목적과 의도: Keep Integrations status available when Qoder cannot represent a catalog model in an exact managed array selector. +- 기존 구현 및 제약 조건: Qoder correctly refuses ambiguous selector construction, but its generic exception escapes the shared reader and fails the whole collection, even without a Qoder installation. +- 검토한 주요 대안: Drop unaddressable models, extend the persisted selector grammar, catch every error in the collection route, or classify a dedicated construction refusal in the shared reader. +- 선택한 방식: Raise a typed selector-construction error and catch only that error around contribution construction in the shared state reader, returning the existing unsafe/unresolvable-path status with resolved target metadata. +- 다른 대안 대신 이 방식을 선택한 이유: This preserves exact ownership and the existing fail-closed preview/write behavior without changing persisted selector interpretation, silently omitting catalog models, or hiding unrelated programming errors. +- 장점, 단점 및 영향: Collection and single-client status remain usable and unrelated clients are unaffected. Qoder management still refuses an unaddressable catalog; the existing generic status reason does not identify the particular model. No selector grammar or writer behavior changes. diff --git a/structure/decisions/ADR-7517-qoder-replay-envelope.md b/structure/decisions/ADR-7517-qoder-replay-envelope.md new file mode 100644 index 00000000000..811b98629fe --- /dev/null +++ b/structure/decisions/ADR-7517-qoder-replay-envelope.md @@ -0,0 +1,12 @@ +# ADR-7517 — decision recorded under "Coding-agent tool ownership" + +- Contract owner: [providers-and-adapters.md](../providers-and-adapters.md) + +## Decision record + +- 목적과 의도: Prevent Qoder assistant output from exposing OpenCodex's own history-replay tool envelopes as a successful answer. +- 기존 구현 및 제약 조건: The Qoder-specific vendor guard recognizes XML-like CLI scaffolding, while the shared coding-agent replay emits bracketed call/result envelopes. The existing line/fence-aware envelope filter already owns that second grammar for other transports. +- 검토한 주요 대안: Duplicate marker strings in the vendor scanner, modify every coding-agent parser, silently truncate the answer, or compose the existing envelope filter at Qoder's output boundary. +- 선택한 방식: Run vendor-cleaned assistant text through the shared envelope filter, settle it before terminal events and before every other non-heartbeat event, and use Qoder's existing non-retryable 502 refusal on matched or unverified envelope output. Keep native tool events and the existing reasoning guard separate. Each settled boundary starts a fresh filter, so the next text block is scanned from a line start. +- 다른 대안 대신 이 방식을 선택한 이유: Reuse preserves split-marker, whole-line, quoted-example and bounded-hold semantics without widening unrelated adapters or maintaining a second marker grammar. A failed answer is explicit rather than an empty successful completion or a hidden billed retry. +- 장점, 단점 및 영향: Ordinary text and proven closed examples survive. An overflowing unverified fenced hold is refused before publication, so a very large unclosed example may fail conservatively. Upstream errors still take precedence when a marker is recognized only at terminal flush. This does not alter CLI resolution, image capability, tool execution or historical request logs. diff --git a/structure/ops/test-sandbox-cleanup.md b/structure/ops/test-sandbox-cleanup.md index 9c68d01cf4e..1caf20bb3d7 100644 --- a/structure/ops/test-sandbox-cleanup.md +++ b/structure/ops/test-sandbox-cleanup.md @@ -78,7 +78,7 @@ case roots (`tests/codex-integration/codex-quota-auto-refresh.test.ts`), and Ant root removal (`tests/helpers/anthropic-instance-fixture.ts`). It settles producers, then each root's config flights, closes the request-history SQLite index, drains each root's ACL work, then asks `nativeMainOwnerHomesOpenAtOrBelow` whether a native-main -owner below the root is still open, acquiring (descriptor and SQLite handle held while its hardener +owner at or below the root is still open, acquiring (descriptor and SQLite handle held while its hardener runs), waiting on a retry, or closing; only roots that pass both are removed. An open owner fails that root at once with an error naming the owner home, without spending the removal retry budget. A producer or history failure blocks every removal; a root-specific drain or owner failure diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index eead725fa20..d5d24f148bc 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -75,7 +75,7 @@ name/namespace components and wire names over 512 UTF-8 bytes before staging. It 256 KiB per serialized definition, and 2 MiB for the serialized array (including brackets and separators); the comma-joined `--allowed-tools` argument is separately capped at 8 KiB of UTF-8, counting full `mcp____` names and commas. Invalid catalogs return a fixed local 400 without spawning Qoder. Valid wire identities, order and schemas are unchanged, apart from the Responses-only encrypted marker stripped during projection. Duplicate and ambiguous wire identities remain the Responses parser's responsibility. Qoder appends the -folded system prompt through its documented scoped `QODER_APPEND_SYSTEM_PROMPT` or `QODERCN_APPEND_SYSTEM_PROMPT` child environment, never through command-line arguments or inherited vendor variables. `src/adapters/qoder/adapter.ts` sets the region's usage switch (`QODER_EXPOSE_TOKEN_USAGE` or `QODERCN_EXPOSE_TOKEN_USAGE`; each CLI build reads only its own prefix) to `true` only in Qoder's child environment, so the shared usage parser receives the CLI's token counts instead of zeros. Executable discovery lives in `src/adapters/coding-agent/profile.ts`. On Windows a PATH directory's same-stem `.exe` beats its `.cmd`, while PATH order still decides between directories. Across a profile's own candidate names the first native hit wins over an earlier batch dispatcher, so the official Qoder CN layout (`qodercn.cmd` forwarding through PowerShell, `qoderclicn.exe` beside it on PATH) launches the executable. Only native hits are cached; a dispatcher-only result is resolved again on the next turn. When a turn must still launch a `.cmd`/`.bat` and its arguments carry an empty value (`--tools ""`, `--setting-sources ""`), `src/adapters/coding-agent/batch-dispatcher.ts` reads the dispatcher, and `turn.ts` refuses with `cli_dispatcher_drops_empty_args` before spawn unless the whole chain is verified: every file read completely within 64 KiB, every hop to another batch file resolved to a literal `%~dp0` or absolute path and inspected in turn (at most four files), and no file forwarding through `powershell`/`pwsh`. A hop through a variable, an oversized or unreadable file, or a deeper chain is refused. A dispatcher that forwards `%*` directly, such as an npm cmd-shim, keeps launching through cmd.exe. `tests/windows/coding-agent-native-empty-argv.test.ts` proves both paths with a native receiver on Windows. +folded system prompt through its documented scoped `QODER_APPEND_SYSTEM_PROMPT` or `QODERCN_APPEND_SYSTEM_PROMPT` child environment, never through command-line arguments or inherited vendor variables. `src/adapters/qoder/adapter.ts` sets the region's usage switch (`QODER_EXPOSE_TOKEN_USAGE` or `QODERCN_EXPOSE_TOKEN_USAGE`; each CLI build reads only its own prefix) to `true` only in Qoder's child environment, so the shared usage parser receives the CLI's token counts instead of zeros. Executable discovery lives in `src/adapters/coding-agent/profile.ts`. On Windows a PATH directory's same-stem `.exe` beats its `.cmd`, while PATH order still decides between directories. Across a profile's own candidate names the first native hit wins over an earlier batch dispatcher, so the official Qoder CN layout (`qodercn.cmd` forwarding through PowerShell, `qoderclicn.exe` beside it on PATH) launches the executable. Only native hits are cached; a dispatcher-only result is resolved again on the next turn. When a turn must still launch a `.cmd`/`.bat` and its arguments carry an empty value (`--tools ""`, `--setting-sources ""`), `src/adapters/coding-agent/batch-dispatcher.ts` reads the dispatcher, and `turn.ts` refuses with `cli_dispatcher_drops_empty_args` before spawn unless the whole chain is verified: every file read completely within 64 KiB, every hop to another batch file resolved to a literal `%~dp0` or absolute path and inspected in turn (at most four files), and no file forwarding through `powershell`/`pwsh`. A hop through a variable, an oversized or unreadable file, or a deeper chain is refused. A dispatcher that forwards `%*` directly, such as an npm cmd-shim, keeps launching through cmd.exe. `tests/windows/coding-agent-native-empty-argv.test.ts` proves both paths with a native receiver on Windows. Qoder's assistant-text replay guard is recorded in [clients/qoder.md](clients/qoder.md#qoder-assistant-text-replay-guard). Coding-agent stdout is framed as bounded JSONL directly from decoded stream segments. The framer tracks the current line's UTF-8 byte count incrementally, searches each decoded segment once, and diff --git a/tests/clients/qoder-client.test.ts b/tests/clients/qoder-client.test.ts index 56605304248..698c37ec0d3 100644 --- a/tests/clients/qoder-client.test.ts +++ b/tests/clients/qoder-client.test.ts @@ -5,10 +5,14 @@ import { dirname, join } from "node:path"; import { buildClientConfigText, buildClientContribution, buildQoderClientConfig, EXPORT_CLIENTS, qoderConfigPath, type ExportContext } from "../../src/clients/config-export"; import { INTEGRATION_CLIENTS } from "../../src/integrations/registry"; import { createIntegrationStateStore } from "../../src/integrations/store"; -import { applyIntegration, disableIntegration, refreshIntegration, restoreIntegration, type IntegrationWriteInput } from "../../src/integrations/writer"; +import { applyIntegration, disableIntegration, overwriteIntegration, refreshIntegration, restoreIntegration, type IntegrationWriteInput } from "../../src/integrations/writer"; +import { previewIntegration } from "../../src/integrations/mutation-plan"; +import { readIntegrationState } from "../../src/integrations/state"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { handleExportCommand } from "../../src/cli/export-command"; +import { canonicalContribution, fingerprint, fragmentPathsOf, semanticContribution } from "../../src/integrations/ownership"; +import type { ManagedContribution } from "../../src/clients/config-export"; import { handleManagementAPI } from "../../src/server/management-api"; const config = { @@ -107,17 +111,17 @@ describe("Qoder client integration", () => { expect(JSON.parse(readFileSync(join(relocated, "settings.json"), "utf8"))).toEqual({ theme: "dark" }); }); - test("exports both JSON catalogs with the requested field casing and normalized model order", () => { + test("exports only the provider catalog with normalized model order and context limits", () => { const doc = buildQoderClientConfig(context); expect(doc.providers.opencodex).toEqual({ type: "openai-compatible", protocol: "openai", displayName: "opencodex", baseUrl: context.baseUrl, apiKey: "opencodex-loopback", defaultModel: "mock/a", - models: [{ model: "mock/a" }, { model: "mock/b" }], + models: [ + { model: "mock/a", displayName: "mock/a", contextWindow: 200000 }, + { model: "mock/b", displayName: "mock/b", contextWindow: 128000 }, + ], }); - expect(doc.modelConfigs.customModels).toEqual([ - { provider: "opencodex", apiKey: "opencodex-loopback", model: "mock/a", baseURL: context.baseUrl, displayName: "mock/a", maxInputTokens: 200000 }, - { provider: "opencodex", apiKey: "opencodex-loopback", model: "mock/b", baseURL: context.baseUrl, displayName: "mock/b", maxInputTokens: 128000 }, - ]); + expect(doc).not.toHaveProperty("modelConfigs"); const exported = buildClientConfigText("qoder", context); expect(exported.format).toBe("json"); expect(exported.mediaType).toContain("application/json"); @@ -144,7 +148,7 @@ describe("Qoder client integration", () => { writeFileSync(f.path, JSON.stringify(applied)); const updated = { ...f.input, models: [context.models[0]!] }; expect(refreshIntegration(updated).ok).toBe(true); - expect(f.read().modelConfigs.customModels).toHaveLength(2); + expect(f.read().modelConfigs.customModels).toHaveLength(1); expect(f.read().modelConfigs.customModels.find((m: { provider: string }) => m.provider === "other")).toEqual(f.original.modelConfigs.customModels[0]); expect(disableIntegration({ ...updated, config: { ...config, hostname: "0.0.0.0" } }).ok).toBe(true); expect(f.read()).toEqual({ ...f.original, theme: "light" }); @@ -162,7 +166,96 @@ describe("Qoder client integration", () => { expect(readFileSync(f.path, "utf8")).toBe(f.before); }); - test("refuses ambiguous model selectors instead of owning another provider's entry", () => { - expect(() => buildClientContribution("qoder", { ...context, models: [{ namespaced: "mock/a,b", provider: "mock", id: "a,b" }] })).toThrow("selector"); + test("provider model IDs do not require legacy array selectors", () => { + const contribution = buildClientContribution("qoder", { + ...context, models: [{ namespaced: "mock/a,b", provider: "mock", id: "a,b" }], + }); + expect(contribution.fragments.map(fragment => fragment.path)).toEqual([["providers", "opencodex"]]); + }); + + test.each([false, true])("legacy refresh removes only owned duplicate rows (edited=%s)", edited => { + const f = fixture(); + expect(applyIntegration(f.input).ok).toBe(true); + const provider = { + type: "openai-compatible", protocol: "openai", displayName: "opencodex", + baseUrl: context.baseUrl, apiKey: "opencodex-loopback", defaultModel: "mock/a", + models: [{ model: "mock/a" }, { model: "mock/b" }], + }; + const legacyModels = [ + { provider: "opencodex", apiKey: "opencodex-loopback", model: "mock/a", baseURL: context.baseUrl, displayName: "mock/a", maxInputTokens: 200000 }, + { provider: "opencodex", apiKey: "opencodex-loopback", model: "mock/b", baseURL: context.baseUrl, displayName: "mock/b", maxInputTokens: 128000 }, + ]; + const legacy: ManagedContribution = { + clientId: "qoder", + fragments: [ + { path: ["providers", "opencodex"], value: provider }, + ...legacyModels.map(model => ({ + path: ["modelConfigs", "customModels", `[v2:provider=opencodex,model=${model.model}]`], value: model, + })), + ], + }; + const document = f.read(); + document.providers.opencodex = provider; + document.modelConfigs.customModels = [...f.original.modelConfigs.customModels, ...legacyModels]; + const text = JSON.stringify(document); + writeFileSync(f.path, text); + f.input.store!.putRecord({ + ...f.input.store!.readRecords().qoder!, + fileFingerprint: fingerprint(text), + blockFingerprint: fingerprint(canonicalContribution(legacy)), + semanticBlockFingerprint: fingerprint(semanticContribution(legacy)), + fragmentPaths: fragmentPathsOf(legacy), + }); + if (edited) document.modelConfigs.customModels[1].maxInputTokens = 123456; + else document.theme = "light"; + const before = JSON.stringify(document); + writeFileSync(f.path, before); + const result = refreshIntegration(f.input); + if (edited) { + expect(result).toMatchObject({ ok: false, reason: "conflict" }); + expect(readFileSync(f.path, "utf8")).toBe(before); + } else { + expect(result).toMatchObject({ ok: true }); + expect(f.read().modelConfigs.customModels).toEqual(f.original.modelConfigs.customModels); + expect(f.read().providers.opencodex).toEqual(buildQoderClientConfig(context).providers.opencodex); + expect(f.input.store!.readRecords().qoder!.fragmentPaths).toEqual([["providers", "opencodex"]]); + expect(refreshIntegration(f.input)).toMatchObject({ ok: true, changed: false }); + expect(disableIntegration(f.input).ok).toBe(true); + expect(f.read()).toEqual({ ...f.original, theme: "light" }); + } + }); + + const unowned = { provider: "opencodex", model: "mock/a", apiKey: "user-placeholder", displayName: "mine" }; + + test.each([false, true])("an unowned opencodex row for an exported model refuses before writing (recorded=%s)", recorded => { + const f = fixture(); + if (recorded) expect(applyIntegration(f.input).ok).toBe(true); + const document = f.read(); + document.modelConfigs.customModels.push(unowned); + const before = JSON.stringify(document, null, 2); + writeFileSync(f.path, before); + const records = JSON.stringify(f.input.store!.readRecords()); + expect(readIntegrationState(f.input)).toMatchObject({ state: "conflict", reason: "unowned-key" }); + for (const operation of ["apply", "overwrite", "disable"] as const) { + expect(previewIntegration(f.input, { operation })).toMatchObject({ canApply: false, refusalReason: "conflict" }); + } + const writes = recorded + ? [refreshIntegration(f.input), applyIntegration(f.input), overwriteIntegration(f.input), disableIntegration(f.input)] + : [applyIntegration(f.input), overwriteIntegration(f.input)]; + for (const result of writes) expect(result).toMatchObject({ ok: false, reason: "conflict", message: expect.stringContaining("modelConfigs.customModels") }); + expect(readFileSync(f.path, "utf8")).toBe(before); + expect(JSON.stringify(f.input.store!.readRecords())).toBe(records); + }); + + test("opencodex rows for other models and other providers' rows for exported models do not conflict", () => { + const f = fixture(); + const document = f.read(); + document.modelConfigs.customModels.push({ ...unowned, model: "mock/z" }, { ...unowned, provider: "elsewhere" }); + writeFileSync(f.path, JSON.stringify(document)); + expect(readIntegrationState(f.input)).toMatchObject({ state: "absent" }); + expect(applyIntegration(f.input)).toMatchObject({ ok: true, state: "current" }); + expect(f.read().modelConfigs.customModels).toEqual(document.modelConfigs.customModels); + expect(disableIntegration(f.input).ok).toBe(true); + expect(f.read()).toEqual(document); }); }); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index fed4bb7ebc7..84e2aa7d4e7 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -865,6 +865,7 @@ "management-client-config-route.test.ts": "server", "management-google-tool-schema-policy.test.ts": "server", "management-inference-port.test.ts": "server", "management-integration-journal-delete.test.ts": "server", "management-integration-routes.test.ts": "server", + "management-qoder-selector-state.test.ts": "server", "management-metrics-export.test.ts": "server", "management-model-roster-gather-race.test.ts": "server", "management-model-roster.test.ts": "server", "model-export-new-model-policy.test.ts": "server", "management-origin-tls.test.ts": "server", "management-provider-compat-carry.test.ts": "server", diff --git a/tests/providers/qoder-adapter.test.ts b/tests/providers/qoder-adapter.test.ts index bba85c4ffff..a4dc66639e2 100644 --- a/tests/providers/qoder-adapter.test.ts +++ b/tests/providers/qoder-adapter.test.ts @@ -51,6 +51,22 @@ function fakeChild(frames: string[], options: { parked?: boolean; exitCode?: num } describe("qoder adapter", () => { + test("joins escaped surrogate halves from separate JSONL frames", async () => { + const frames = [ + { type: "stream_event", event: { type: "content_block_delta", delta: { type: "text_delta", text: "a <\uD83D" } } }, + { type: "stream_event", event: { type: "content_block_delta", delta: { type: "thinking_delta", thinking: "Reasoning." } } }, + { type: "stream_event", event: { type: "content_block_delta", delta: { type: "text_delta", text: "\uDE00" } } }, + { type: "result", subtype: "success", is_error: false, result: "" }, + ].map(frame => JSON.stringify(frame) + "\n"); + const events: AdapterEvent[] = []; + await createQoderAdapter(provider(), { which: () => "/bin/qoder", spawn: () => fakeChild(frames) }) + .runTurn!(parsed(), { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + const text = events.filter((event): event is Extract => event.type === "text_delta"); + expect(text.map(event => event.text).join("")).toBe("a <😀"); + for (const event of text) expect(event.text.isWellFormed()).toBe(true); + expect(events.at(-1)?.type).toBe("done"); + }); + test.each([false, true])("unfinished repeated-ID blocks are bounded before a terminal frame (already emitted=%s)", async alreadyEmitted => { const id = "same_call"; const name = "mcp__opencodex__probe_echo"; @@ -1246,7 +1262,7 @@ describe("qoder authoritative tool-turn completion", () => { expect(events.at(-1)).toMatchObject({ type: "done", stopReason: "tool_use", endTurn: false }); }); - test("scaffold refusal suppresses authoritative success terminal", async () => { + for (const scaffold of ["tail", 'Safe.\n[Tool call: probe (call_id: fixture) with args: {"input":"private-fixture"}]']) test("scaffold refusal suppresses authoritative success terminal: " + scaffold.slice(0, 16), async () => { const { spawned, clock, parked, adapter } = setupParkedQoder(); const events: AdapterEvent[] = []; const turnPromise = adapter.runTurn!(toolRequest(), { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, e => events.push(e)); @@ -1255,7 +1271,7 @@ describe("qoder authoritative tool-turn completion", () => { parked.pushFrame({ type: "system", subtype: "init", mcp_servers: [{ name: "opencodex", status: "connected" }] }); parked.pushFrame({ type: "assistant", - message: { content: [{ type: "text", text: "tail" }] }, + message: { content: [{ type: "text", text: scaffold }] }, }); parked.pushFrame({ type: "assistant", diff --git a/tests/providers/qoder-scaffold-guard.test.ts b/tests/providers/qoder-scaffold-guard.test.ts index 4c7a26d5143..b30723d3a0a 100644 --- a/tests/providers/qoder-scaffold-guard.test.ts +++ b/tests/providers/qoder-scaffold-guard.test.ts @@ -276,3 +276,297 @@ describe("guardQoderScaffolding", () => { expect(events.map(event => event.type)).toEqual(["tool_call_start", "done"]); }); }); + + +describe("Qoder replay envelope refusal (#7517)", () => { + for (const marker of ["[Tool call: probe (call_id: call_fixture) with args: ", "[Tool Result]\n", "[Tool Error]\n", "[tool_result]\n", "[Tool Call]\n"]) { + for (const size of [1, 2, 7, 64]) test(`refuses ${marker.trim()} split in ${size}-character deltas`, () => { + const { events, emit } = collect(); + const guard = guardQoderScaffolding(emit); + const text = "Safe answer.\n" + marker + JSON.stringify({ input: "private-fixture\n".repeat(6000) }); + for (let at = 0; at < text.length; at += size) guard({ type: "text_delta", text: text.slice(at, at + size) }); + guard({ type: "done", stopReason: "stop" }); + expect(textOf(events)).toBe("Safe answer.\n"); + expect(events.filter(event => event.type === "error")).toHaveLength(1); + expect(events.at(-1)).toMatchObject({ type: "error", code: QODER_SCAFFOLD_ERROR_CODE, status: 502, retryable: false }); + expect(JSON.stringify(events)).not.toContain("private-fixture"); + expect(events.some(event => event.type === "done")).toBe(false); + }); + } + + for (const marker of ["[Tool Result", "[Tool Call"]) for (const terminal of ["done", "incomplete", "error"] as const) test(`settles trailing ${marker} before ${terminal}`, () => { + const { events, emit } = collect(); + const guard = guardQoderScaffolding(emit); + guard({ type: "text_delta", text: "Safe.\n" + marker }); + const end: AdapterEvent = terminal === "done" ? { type: "done", stopReason: "stop" } + : terminal === "incomplete" ? { type: "incomplete", reason: "max_output_tokens" } + : { type: "error", message: "original vendor error", status: 429 }; + guard(end); + expect(textOf(events)).toBe("Safe.\n"); + expect(events.at(-1)).toMatchObject(terminal === "error" ? end : { type: "error", status: 502, retryable: false }); + }); + + test("keeps ordinary marker prose, closed fenced examples and partial nonmarkers", () => { + const { events, emit } = collect(); + const guard = guardQoderScaffolding(emit); + const answer = "[Tool Result] means a returned value.\n```text\n[Tool call: example]\nquoted payload\n```\nFinal [Tool Res"; + for (const text of answer) guard({ type: "text_delta", text }); + guard({ type: "done", stopReason: "stop" }); + expect(textOf(events)).toBe(answer); + expect(events.at(-1)?.type).toBe("done"); + }); + + test("refuses an unclosed fenced envelope without releasing an overflowing hold", () => { + const { events, emit } = collect(); + const guard = guardQoderScaffolding(emit); + guard({ type: "text_delta", text: "```text\n[Tool call: probe]\n" + "private-fixture".repeat(6000) }); + guard({ type: "done", stopReason: "stop" }); + expect(textOf(events)).not.toContain("[Tool call:"); + expect(textOf(events)).not.toContain("private-fixture"); + expect(events.at(-1)).toMatchObject({ type: "error", status: 502, retryable: false }); + }); + + test("a tool event settles held text first, so it neither overtakes prose nor leaks after an envelope", () => { + const tool = (guard: (event: AdapterEvent) => void) => { + guard({ type: "tool_call_start", id: "call_1", name: "exec" }); + guard({ type: "tool_call_delta", arguments: "{}" }); + guard({ type: "tool_call_end" }); + guard({ type: "done", stopReason: "stop" }); + }; + const prose = collect(); + const guarded = guardQoderScaffolding(prose.emit); + guarded({ type: "text_delta", text: "Checking:\n[" }); + tool(guarded); + expect(prose.events.map(event => event.type)).toEqual(["text_delta", "text_delta", "tool_call_start", "tool_call_delta", "tool_call_end", "done"]); + expect(textOf(prose.events)).toBe("Checking:\n["); + for (const echo of ["[Tool Result]", "Safe.\n[Tool Error]", "```text\n[Tool call: probe]\nprivate-fixture"]) { + const refused = collect(); + const guard = guardQoderScaffolding(refused.emit); + guard({ type: "text_delta", text: echo }); + tool(guard); + expect(refused.events.some(event => event.type.startsWith("tool_call"))).toBe(false); + expect(JSON.stringify(refused.events)).not.toContain("private-fixture"); + expect(refused.events.at(-1)).toMatchObject({ type: "error", code: QODER_SCAFFOLD_ERROR_CODE, status: 502, retryable: false }); + } + }); + + test("text after a tool event starts a new line for envelope detection", () => { + const { events, emit } = collect(); + const guard = guardQoderScaffolding(emit); + guard({ type: "text_delta", text: "Working" }); + guard({ type: "tool_call_start", id: "call_1", name: "exec" }); + guard({ type: "tool_call_end" }); + guard({ type: "text_delta", text: "[Tool Result]\nprivate-fixture" }); + guard({ type: "done", stopReason: "stop" }); + expect(textOf(events)).toBe("Working"); + expect(JSON.stringify(events)).not.toContain("private-fixture"); + expect(events.at(-1)).toMatchObject({ type: "error", code: QODER_SCAFFOLD_ERROR_CODE, status: 502 }); + }); + + test("thinking cannot overtake replay-held prose", () => { + const { events, emit } = collect(); + const guard = guardQoderScaffolding(emit); + guard({ type: "text_delta", text: "[" }); + guard({ type: "thinking_delta", thinking: "Reasoning." }); + guard({ type: "done", stopReason: "stop" }); + expect(events).toEqual([ + { type: "text_delta", text: "[" }, + { type: "thinking_delta", thinking: "Reasoning." }, + { type: "done", stopReason: "stop" }, + ]); + }); + + for (const echo of ["[Tool Result]", "```text\n[Tool call: probe]\nprivate-fixture"]) { + test(`settles ${JSON.stringify(echo)} before thinking`, () => { + const { events, emit } = collect(); + const guard = guardQoderScaffolding(emit); + guard({ type: "text_delta", text: echo }); + guard({ type: "thinking_delta", thinking: "Reasoning." }); + guard({ type: "done", stopReason: "stop" }); + expect(events.some(event => event.type === "thinking_delta")).toBe(false); + expect(JSON.stringify(events)).not.toContain("private-fixture"); + expect(events.at(-1)).toMatchObject({ type: "error", code: QODER_SCAFFOLD_ERROR_CODE, status: 502, retryable: false }); + }); + } + + for (const before of ["Working", "```text\n"]) { + test(`thinking resets replay line and fence state after ${JSON.stringify(before)}`, () => { + const { events, emit } = collect(); + const guard = guardQoderScaffolding(emit); + guard({ type: "text_delta", text: before }); + guard({ type: "thinking_delta", thinking: "Reasoning." }); + guard({ type: "thinking_delta", thinking: "More reasoning." }); + guard({ type: "text_delta", text: "[Tool Result]\nprivate-fixture\n```\n" }); + guard({ type: "done", stopReason: "stop" }); + expect(textOf(events)).toBe(before); + expect(JSON.stringify(events)).not.toContain("private-fixture"); + expect(events.at(-1)).toMatchObject({ type: "error", code: QODER_SCAFFOLD_ERROR_CODE, status: 502 }); + }); + } + + for (const terminal of ["done", "error"] as const) { + test(`keeps marker prose with a held vendor prefix across thinking before ${terminal}`, () => { + const { events, emit } = collect(); + const guard = guardQoderScaffolding(emit); + guard({ type: "text_delta", text: "[Tool Result] { + const { events, emit } = collect(); + const guard = guardQoderScaffolding(emit); + guard({ type: "text_delta", text: "Safe.private-fixtureAnswer." }); + guard({ type: "done", stopReason: "stop" }); + expect(textOf(events)).toBe("Safe.Answer."); + expect(JSON.stringify(events)).not.toContain("private-fixture"); + expect(events.at(-1)?.type).toBe("done"); + }); + + test("heartbeats keep a split replay marker in the same text block", () => { + const { events, emit } = collect(); + const guard = guardQoderScaffolding(emit); + guard({ type: "text_delta", text: "[Tool " }); + guard({ type: "heartbeat" }); + guard({ type: "text_delta", text: "Result]\nprivate-fixture" }); + guard({ type: "done", stopReason: "stop" }); + expect(textOf(events)).toBe(""); + expect(JSON.stringify(events)).not.toContain("private-fixture"); + expect(events.at(-1)).toMatchObject({ type: "error", code: QODER_SCAFFOLD_ERROR_CODE }); + }); + + test("preserves multiple thinking cuts within one pending vendor prefix", () => { + const { events, emit } = collect(); + const guard = guardQoderScaffolding(emit); + guard({ type: "text_delta", text: "<" }); + guard({ type: "thinking_delta", thinking: "A" }); + guard({ type: "text_delta", text: "s" }); + guard({ type: "thinking_delta", thinking: "B" }); + guard({ type: "text_delta", text: "x" }); + guard({ type: "done", stopReason: "stop" }); + expect(events).toEqual([ + { type: "text_delta", text: "<" }, { type: "thinking_delta", thinking: "A" }, + { type: "text_delta", text: "s" }, { type: "thinking_delta", thinking: "B" }, + { type: "text_delta", text: "x" }, { type: "done", stopReason: "stop" }, + ]); + }); + + for (const character of ["😀", "𠀀"]) test(`preserves astral character ${character} after a pending vendor boundary`, () => { + const { events, emit } = collect(); + const guard = guardQoderScaffolding(emit); + guard({ type: "text_delta", text: "a <" }); + guard({ type: "thinking_delta", thinking: "Reasoning." }); + guard({ type: "text_delta", text: character }); + guard({ type: "done", stopReason: "stop" }); + expect(textOf(events)).toBe(`a <${character}`); + for (const event of events) if (event.type === "text_delta") expect(event.text.isWellFormed()).toBe(true); + expect(events.at(-1)?.type).toBe("done"); + }); + + for (const prefix of ["", "a <"]) for (const character of ["😀", "𠀀"]) test(`joins split input ${character} after ${JSON.stringify(prefix)}`, () => { + const { events, emit } = collect(); + const guard = guardQoderScaffolding(emit); + guard({ type: "text_delta", text: prefix }); + guard({ type: "thinking_delta", thinking: "Before." }); + guard({ type: "text_delta", text: character[0]! }); + guard({ type: "thinking_delta", thinking: "Between." }); + guard({ type: "heartbeat" }); + guard({ type: "text_delta", text: "" }); + guard({ type: "text_delta", text: character[1]! + " suffix" }); + guard({ type: "done", stopReason: "stop" }); + expect(textOf(events)).toBe(prefix + character + " suffix"); + for (const event of events) if (event.type === "text_delta") expect(event.text.isWellFormed()).toBe(true); + const scalar = events.findIndex(event => event.type === "text_delta" && event.text.includes(character)); + expect(scalar).toBeGreaterThan(events.findIndex(event => event.type === "thinking_delta" && event.thinking === "Before.")); + expect(scalar).toBeLessThan(events.findIndex(event => event.type === "thinking_delta" && event.thinking === "Between.")); + expect(events.at(-1)?.type).toBe("done"); + }); + + test("deferred events stay after a joined scalar and before a fresh replay block", () => { + const { events, emit } = collect(); + const guard = guardQoderScaffolding(emit); + guard({ type: "text_delta", text: "\uD83D" }); + guard({ type: "thinking_delta", thinking: "A" }); + guard({ type: "tool_call_start", id: "call_1", name: "exec" }); + guard({ type: "thinking_delta", thinking: "B" }); + guard({ type: "text_delta", text: "\uDE00[Tool Result]\nprivate-fixture" }); + guard({ type: "done", stopReason: "stop" }); + expect(textOf(events)).toBe("😀"); + expect(events.map(event => event.type)).toEqual(["text_delta", "thinking_delta", "tool_call_start", "thinking_delta", "error"]); + expect(JSON.stringify(events)).not.toContain("private-fixture"); + expect(events.at(-1)).toMatchObject({ type: "error", code: QODER_SCAFFOLD_ERROR_CODE }); + }); + + for (const terminal of ["done", "incomplete", "error"] as const) test(`settles an unpaired high before ${terminal}`, () => { + const { events, emit } = collect(); + const guard = guardQoderScaffolding(emit); + guard({ type: "text_delta", text: "a <\uD83D" }); + guard({ type: "thinking_delta", thinking: "Reasoning." }); + const end: AdapterEvent = terminal === "done" ? { type: "done", stopReason: "stop" } + : terminal === "incomplete" ? { type: "incomplete", reason: "max_output_tokens" } + : { type: "error", message: "original vendor error", status: 429 }; + guard(end); + expect(textOf(events)).toBe("a <�"); + for (const event of events) if (event.type === "text_delta") expect(event.text.isWellFormed()).toBe(true); + expect(events.at(-1)).toEqual(end); + expect(events.at(-2)).toEqual({ type: "thinking_delta", thinking: "Reasoning." }); + }); + + for (const [chunks, expected] of [ + [["\uDE00"], "�"], [["\uD83D", "x"], "�x"], [["\uD83D", "\uD83D", "\uDE00"], "�😀"], + ] as const) test(`replaces unpaired input ${JSON.stringify(chunks)}`, () => { + const { events, emit } = collect(); + const guard = guardQoderScaffolding(emit); + for (const text of chunks) guard({ type: "text_delta", text }); + guard({ type: "done", stopReason: "stop" }); + expect(textOf(events)).toBe(expected); + for (const event of events) if (event.type === "text_delta") expect(event.text.isWellFormed()).toBe(true); + }); + + test("pending boundaries never release split vendor tool markup", () => { + const { events, emit } = collect(); + const guard = guardQoderScaffolding(emit); + guard({ type: "text_delta", text: "private-fixture" }); + guard({ type: "done", stopReason: "stop" }); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: "error", code: QODER_SCAFFOLD_ERROR_CODE, retryable: false }); + expect(JSON.stringify(events)).not.toContain("private-fixture"); + }); + + for (const suffix of ["<", "\uD83D"]) for (const terminal of ["done", "incomplete", "error"] as const) test(`withholds held reasoning when draining an envelope with ${JSON.stringify(suffix)} before ${terminal}`, () => { + const { events, emit } = collect(); + const guard = guardQoderScaffolding(emit); + guard({ type: "text_delta", text: "```text\n[Tool Result]\npayload" + suffix }); + guard({ type: "thinking_delta", thinking: " event.type === "thinking_delta")).toBe(false); + expect(events.at(-1)).toMatchObject(terminal === "error" ? end : { type: "error", code: QODER_SCAFFOLD_ERROR_CODE, retryable: false }); + }); + + for (const prefix of [" { + const { events, emit } = collect(); + const guard = guardQoderScaffolding(emit); + guard({ type: "text_delta", text: prefix }); + if (overflow === "characters") guard({ type: "thinking_delta", thinking: "private-fixture".repeat(10_000) }); + else for (let index = 0; index < 2_000; index++) guard({ type: "tool_call_end" }); + guard({ type: "done", stopReason: "stop" }); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: "error", code: QODER_SCAFFOLD_ERROR_CODE, retryable: false }); + expect(JSON.stringify(events)).not.toContain("private-fixture"); + }); + } +}); diff --git a/tests/server/management-qoder-selector-state.test.ts b/tests/server/management-qoder-selector-state.test.ts new file mode 100644 index 00000000000..133ff8cd4d2 --- /dev/null +++ b/tests/server/management-qoder-selector-state.test.ts @@ -0,0 +1,160 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { EXPORT_CLIENTS, qoderConfigPath } from "../../src/clients/config-export"; +import { UnrepresentableSelectorError } from "../../src/integrations/merge"; +import { INTEGRATION_CLIENT_IDS } from "../../src/integrations/registry"; +import { readIntegrationState } from "../../src/integrations/state"; +import { createIntegrationStateStore } from "../../src/integrations/store"; +import { handleManagementAPI } from "../../src/server/management-api"; +import { setIntegrationMutationFlightTestHooks, setIntegrationPathTestHooks } from "../../src/server/management/integration-routes"; +import { resetExportSnapshotForTests } from "../../src/server/management/model-rows"; +import type { OcxConfig } from "../../src/types"; +import { catalogConvergenceFactory } from "../helpers/catalog-convergence"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +let root: string; +let home: string; +let config: OcxConfig; +let store: ReturnType; +let server: ReturnType; +let persisted: number; + +function witness(): string { + return readdirSync(root, { recursive: true }).map(String).sort().map(path => { + const full = join(root, path); + return statSync(full).isDirectory() ? `${path}/` : `${path}:${readFileSync(full, "utf8")}`; + }).join("\n"); +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-qoder-selector-state-")); + home = join(root, "home"); + mkdirSync(home); + store = createIntegrationStateStore(join(root, "integrations")); + persisted = 0; + config = { + port: 10100, hostname: "127.0.0.1", defaultProvider: "fixture", + providers: { fixture: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1", liveModels: false, models: ["k3"] } }, + } as OcxConfig; + setIntegrationPathTestHooks({ home, env: {} }); + setIntegrationMutationFlightTestHooks({ store }); + resetExportSnapshotForTests(); + server = Bun.serve({ + hostname: "127.0.0.1", port: 0, + fetch: async req => await handleManagementAPI(req, new URL(req.url), config, { + saveConfigPreservingClaudeCode: () => { persisted += 1; }, + createManagementConvergeCodex: catalogConvergenceFactory(), + }) ?? new Response("not found", { status: 404 }), + }); +}); + +afterEach(async () => { + await server.stop(true); + setIntegrationPathTestHooks(null); + setIntegrationMutationFlightTestHooks(null); + resetExportSnapshotForTests(); + removeTreeWithRetry(root); +}); + +function api(path = "", body?: unknown) { + return fetch(new URL(`/api/client-integrations${path}`, server.url), body === undefined ? undefined : { + method: path === "/qoder" ? "PUT" : "POST", + headers: { "content-type": "application/json" }, body: JSON.stringify(body), + }); +} + +for (const model of ["k3[1m]", "k3,other"]) { + test(`a selector-hostile Qoder model (${model}) does not break collection or single status`, async () => { + const control = await (await api("/hermes")).json(); + config.providers.fixture!.models = ["k3", model]; + const before = witness(); + const response = await api(); + expect(response.status).toBe(200); + const { clients } = await response.json() as { clients: Array<{ clientId: string }> }; + expect(clients.map(client => client.clientId)).toEqual(INTEGRATION_CLIENT_IDS); + const qoder = clients.find(client => client.clientId === "qoder"); + // The provider-only catalog needs no array selector, so the ID is ordinary. + expect(qoder).toMatchObject({ + state: "absent", installed: false, + configPath: qoderConfigPath({}, home), snapshotCount: 0, retentionDegraded: false, + }); + expect(clients.find(client => client.clientId === "hermes")).toEqual(control); + const single = await api("/qoder"); + expect(single.status).toBe(200); + expect(await single.json()).toEqual(qoder); + expect(witness()).toBe(before); + expect(persisted).toBe(0); + }); +} + +for (const operation of ["apply", "overwrite", "disable"] as const) for (const surface of ["preview", "mutation"] as const) test(`a typed selector refusal stays unsafe in ${operation} ${surface}`, async () => { + const path = qoderConfigPath({}, home); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify({ theme: "user-owned" })); + const builder = spyOn(EXPORT_CLIENTS.qoder, "buildContribution").mockImplementation(() => { + throw new UnrepresentableSelectorError("fixture cannot name its owned element"); + }); + try { + const before = witness(); + const response = await api(); // Also populates the authoritative preview roster. + expect(response.status).toBe(200); + const { clients } = await response.json() as { clients: Array<{ clientId: string }> }; + expect(clients.map(client => client.clientId)).toEqual(INTEGRATION_CLIENT_IDS); + expect(await (await api("/qoder")).json()).toMatchObject({ + state: "unsafe", reason: "unresolvable-path", installed: true, configPath: path, + }); + if (surface === "preview") { + const preview = await api("/preview", { clientId: "qoder", operation }); + expect(preview.status).toBe(200); + expect(await preview.json()).toMatchObject({ canApply: false, willChange: false, state: "unsafe", refusalReason: "unsafe" }); + } else { + const mutation = await api("/qoder", { enabled: operation !== "disable", overwriteConflict: operation === "overwrite" }); + expect(mutation.status).toBe(409); + expect(await mutation.json()).toMatchObject({ code: "integration_unsafe", reason: "unsafe", state: "unsafe" }); + } + expect(witness()).toBe(before); + expect(persisted).toBe(0); + } finally { + builder.mockRestore(); + } +}); + +test("Qoder catalogs with selector-hostile IDs support status, preview and apply", async () => { + mkdirSync(dirname(qoderConfigPath({}, home)), { recursive: true }); + config.providers.fixture!.models = ["k3", "k3[1m]"]; + expect((await api()).status).toBe(200); + expect(await (await api("/qoder")).json()).toMatchObject({ state: "absent", installed: true }); + const preview = await api("/preview", { clientId: "qoder", operation: "apply" }); + expect(preview.status).toBe(200); + expect(await preview.json()).toMatchObject({ canApply: true }); + expect((await api("/qoder", { enabled: true })).status).toBe(200); + expect(await (await api("/qoder")).json()).toMatchObject({ state: "current" }); + const written = JSON.parse(readFileSync(qoderConfigPath({}, home), "utf8")); + expect(written.providers.opencodex.models.map((row: { model: string }) => row.model)).toEqual(expect.arrayContaining(["fixture/k3", "fixture/k3[1m]"])); + expect(written).not.toHaveProperty("modelConfigs"); +}); + +test("unexpected contribution errors still propagate instead of becoming unsafe refusals", async () => { + mkdirSync(dirname(qoderConfigPath({}, home)), { recursive: true }); + expect((await api()).status).toBe(200); + const error = new Error("unexpected builder failure"); + const builder = spyOn(EXPORT_CLIENTS.qoder, "buildContribution").mockImplementation(() => { throw error; }); + try { + expect(() => readIntegrationState({ clientId: "qoder", config, port: 10100, models: [], home, env: {}, store })).toThrow(error); + const before = witness(); + const preview = await api("/preview", { clientId: "qoder", operation: "apply" }); + expect(preview.status).toBe(500); + expect(await preview.json()).toMatchObject({ code: "integration_internal_error", error: error.message }); + for (const enabled of [true, false]) { + const mutation = await api("/qoder", { enabled }); + expect(mutation.status).toBe(500); + expect(await mutation.json()).toMatchObject({ code: "integration_internal_error", error: error.message }); + } + expect(witness()).toBe(before); + expect(persisted).toBe(0); + } finally { + builder.mockRestore(); + } +}); From 8066099651f8ed356bb73a67d9c99978ea65447c Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 24 Oct 2026 18:19:43 +0900 Subject: [PATCH 012/101] fix(codex): list pool accounts without a native home and expose safe slot identity metadata (#7551) Codex account list: keep each registered slotId and slotKind on account-list rows and add an optional upstreamAccountKey, a versioned HMAC of the ChatGPT account and user claims under the local administrator token. The native key requires one token that claims both the matching account and the user; missing or conflicting claims omit it. No raw identity or credential is serialized. Account listing and explicit refresh return stored pool accounts without creating a missing native CODEX_HOME (maintainer decision D2); non-directory, dangling and untraversable homes still fail closed. Co-authored-by: alexalok <5950085+alexalok@users.noreply.github.com> Co-authored-by: Plamyth <145187149+Plamyth@users.noreply.github.com> --- .../fr/reference/cli/providers-accounts.md | 9 + .../ja/reference/cli/providers-accounts.md | 8 + .../ko/reference/cli/providers-accounts.md | 16 + .../docs/reference/cli/providers-accounts.md | 15 + .../ru/reference/cli/providers-accounts.md | 8 + .../tr/reference/cli/providers-accounts.md | 8 + .../zh-cn/reference/cli/providers-accounts.md | 6 + .../zh-tw/reference/cli/providers-accounts.md | 5 + .../test-layout/layout-codex-collision.json | 1 + scripts/test-layout/layout.json | 4 +- src/cli/account-api.ts | 11 + src/codex/auth-api/account-identity.ts | 14 + src/codex/auth-api/account-list.ts | 31 +- src/codex/auth-api/http.ts | 13 + src/codex/auth-api/main-account-probe.ts | 4 +- src/codex/collision-identity.ts | 35 ++- src/codex/native-profile-store.ts | 3 +- structure/providers/openai-accounts.md | 6 +- tests/cli/cli-account-slot-identity.test.ts | 56 ++++ tests/cli/cli-dto-fidelity.test.ts | 29 ++ .../codex-account-slot-identity.test.ts | 174 +++++++++++ .../codex-auth-headless-accounts.test.ts | 277 ++++++++++++++++++ .../codex-stored-main-duplicate.test.ts | 79 ++++- tests/fixtures/test-layout-expected.json | 3 + 24 files changed, 796 insertions(+), 19 deletions(-) create mode 100644 src/codex/auth-api/account-identity.ts create mode 100644 tests/cli/cli-account-slot-identity.test.ts create mode 100644 tests/codex-integration/codex-account-slot-identity.test.ts create mode 100644 tests/codex-integration/codex-auth-headless-accounts.test.ts diff --git a/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md b/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md index 89d1e6773f6..25e82d8de21 100644 --- a/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md @@ -164,6 +164,15 @@ et la colonne plan/label recouvre le plan, l'e-mail masqué, l'étiquette et la ### `ocx account list [provider] [--json] [--all]` +Les comptes ChatGPT enregistrés dans le pool restent consultables sur les hôtes sans répertoire +Codex natif, y compris dans le tableau de bord et lors de l'actualisation des comptes. Cette consultation +ne crée pas de répertoire Codex et ne déconnecte pas le compte principal natif. Désactiver l'intégration +du client Codex ne désactive pas l'observation du quota d'une connexion native existante. Un chemin +`CODEX_HOME` explicite invalide continue de produire une erreur de configuration. Un stockage +`OPENCODEX_HOME` non sûr produit une erreur même si le répertoire Codex natif est absent. La résolution +du chemin Codex par défaut renvoie également une erreur s'il désigne un fichier, un lien symbolique +dont la cible n'existe pas, ou si son répertoire parent ne peut pas être traversé. + Sans fournisseur, répertorie le groupe de comptes Codex, les comptes OAuth et les groupes de clés API configurés. Les fournisseurs vides sont ignorés à moins que `--all` soit présent. Avec un fournisseur, répertorie uniquement cette famille d’informations d’identification. La sortie destinée aux utilisateurs utilise `PROVIDER TYPE ID PLAN/LABEL PRIORITY STATUS` ; une ligne Codex sélectionnée manuellement porte la mention diff --git a/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md index f920a33c459..7e2a2c06150 100644 --- a/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md @@ -131,6 +131,14 @@ Codex pool selection applies to the next request after clearing existing affinit ### `ocx account list [provider] [--json] [--all] [--quota [--refresh]]` +保存済みの ChatGPT プールアカウントは、ネイティブ Codex ホームがないホストでも、 +ダッシュボードやアカウント更新時を含めて一覧表示できます。一覧表示によって Codex ホームが +作成されたり、ネイティブのメインアカウントからサインアウトしたりすることはありません。 +Codex クライアント連携を無効にしても、既存のネイティブログインのクォータ観測は無効になりません。 +明示的に指定した `CODEX_HOME` のパスが無効な場合は、引き続き設定エラーを返します。 +`OPENCODEX_HOME` のストレージが安全でない場合は、ネイティブ Codex ホームがなくてもエラーを返します。 +既定の Codex ホームのパス解決では、ファイル、リンク先が存在しないシンボリックリンク、辿れない親ディレクトリもエラーになります。 + プロバイダーを使用しない場合、Codex プール、OAuth アカウント、および設定された API キー プールが一覧表示されます。 `--all` が存在しない限り、空のプロバイダーはスキップされます。プロバイダーを使用すると、その資格情報ファミリーのみがリストされます。人間の出力では `PROVIDER TYPE ID PLAN/LABEL PRIORITY STATUS` を使用します。手動で選択した Codex 行には `selected` というマークが付けられます。利用可能な Kiro アカウントが 2 つ以上保存されている場合、既定では 429 を受けると別のアカウントへ自動的に切り替え、既知の残り利用枠が最も多いアカウントを優先します。この切り替えはアカウントの存在によって有効になり、無効にはできません。`oauthAccountFailover.enabled: false` が断るのは 429 復旧ではなく、送信前にアカウントを選ぶ動作だけです。`ocx account login kiro` はアカウントを 1 件ずつプールへ追加します。結果が空であっても成功です。 `--json` は次を返します: ```text diff --git a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md index a60a7f21685..a0dae108083 100644 --- a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md @@ -241,12 +241,28 @@ Codex pool selection applies to the next request after clearing existing affinit ### `ocx account list [provider] [--json] [--all] [--quota [--refresh]]` +저장된 ChatGPT 풀 계정은 네이티브 Codex 홈이 없는 호스트에서도 대시보드와 계정 새로 고침을 +포함해 계속 나열할 수 있습니다. 목록 조회는 Codex 홈을 만들거나 네이티브 메인 계정에서 +로그아웃하지 않습니다. Codex 클라이언트 통합을 비활성화해도 기존 네이티브 로그인의 할당량 +관측은 비활성화되지 않습니다. 명시적으로 지정한 `CODEX_HOME` 경로가 유효하지 않으면 계속 +설정 오류를 반환합니다. `OPENCODEX_HOME` 저장소가 안전하지 않으면 네이티브 Codex 홈이 +없어도 오류를 반환합니다. +기본 Codex 홈의 경로를 확인할 때도 파일, 대상이 없는 심볼릭 링크, 탐색할 수 없는 상위 디렉터리는 오류를 발생시킵니다. + 제공자를 지정하지 않으면 Codex 풀, OAuth 계정, 설정된 API 키 풀을 나열합니다. `--all`이 없으면 비어 있는 제공자는 건너뜁니다. 제공자를 지정하면 해당 자격 증명 계열만 나열합니다. 사람이 보는 출력은 `PROVIDER TYPE ID PLAN/LABEL PRIORITY STATUS` 형식을 사용하며, 수동으로 선택한 Codex 행에는 `selected`가 표시됩니다. 사용 가능한 Kiro 계정이 두 개 이상 저장되어 있으면 기본적으로 429 응답 시 다른 계정으로 자동 전환하며, 알려진 잔여 할당량이 가장 많은 계정을 우선합니다. 이 전환은 계정 존재만으로 활성화되며 끌 수 없습니다. `oauthAccountFailover.enabled: false`는 429 복구가 아니라 요청을 보내기 전 계정을 고르는 동작만 거부합니다. `ocx account login kiro`는 계정을 한 번에 하나씩 풀에 추가합니다. 빈 결과도 성공입니다. `--json`은 다음을 반환합니다: ```text { accounts: AccountRow[], notes: string[] } ``` +Codex JSON 행에는 등록된 `id`와 같은 `slotId`, 그리고 `native-main` 또는 `pool`인 +`slotKind`가 포함됩니다. 프록시가 상위 계정과 사용자 식별값을 모두 확인하면 +`uai1_` 뒤에 소문자 16진수 64자리가 붙는 `upstreamAccountKey`도 제공합니다. +같은 `openai` 제공자에서 키가 같으면 한 상위 사용자의 슬롯이며, 각 슬롯과 한도 상태는 +그대로 유지해야 합니다. 키는 이 설치 환경에 한정되고 관리자 토큰이 바뀌면 달라질 수 +있습니다. 키가 없을 때는 가려진 이메일이나 한도가 비슷하다는 이유로 묶지 않습니다. +상위 계정 ID·사용자 식별값·인증 정보 원문은 목록에 포함되지 않습니다. + ### `ocx account current [--json]` 활성 계정이나 키를 보여줍니다. 수동 고정이 없는 Codex 풀은 우선순위를 고려한 자동 선택을 보고합니다. 가장 우선순위가 높은 적격 tier를 고르고, 그 tier 안에서 할당량 라우팅 기준으로 가장 적게 사용한 항목을 선택합니다. 활성 자격 증명이 없는 다른 계열은 그 상태를 보고하고도 종료 코드 0으로 끝납니다. `--json`은 다음을 반환합니다: diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index 30a9732d990..46a343e2b85 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -674,6 +674,13 @@ changes Orca files, or changes the active pool account. ### `ocx account list [provider] [--json] [--all] [--quota [--refresh]]` +Stored ChatGPT pool accounts remain listable on hosts without a native Codex home, including in the +dashboard and on account refresh. Listing does not create a Codex home or sign out the native main +account. Disabling the Codex client integration does not disable an existing native login's quota +observation; invalid explicit `CODEX_HOME` paths still report a configuration error. Unsafe +`OPENCODEX_HOME` storage reports an error even when the native Codex home is missing. +Default-home resolution also reports errors for files, dangling links, and parent directories that cannot be traversed. + Without a provider, lists the Codex pool, OAuth accounts, and configured API-key pools. Empty providers are skipped unless `--all` is present. With a provider, lists only that credential family. Human output uses `PROVIDER TYPE ID PLAN/LABEL PRIORITY STATUS`; a manually chosen Codex row is marked @@ -691,6 +698,14 @@ returns: { accounts: AccountRow[], notes: string[] } ``` +Codex JSON rows include `slotId` (the registered `id`) and `slotKind` (`native-main` or +`pool`). When the proxy can verify both the upstream account and user identity, it also +includes `upstreamAccountKey` (`uai1_` followed by 64 lowercase hexadecimal characters). +Equal keys within the `openai` provider identify slots for the same upstream user; keep +each slot and its quota state separately. The key is local to this installation and can +change when its administrator token rotates. If it is absent, do not infer a match from +masked email or quota values. Raw upstream IDs, user claims and credentials are not listed. + An empty provider listing names the next command in human output and JSON `notes`: `ocx account login ` for OAuth/Codex, or `ocx account add-key ` with a human-controlled piped stdin source for API keys. An empty global listing diff --git a/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md index 87a58230f1d..c1a53a5103f 100644 --- a/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md @@ -154,6 +154,14 @@ label и masked key. ### `ocx account list [provider] [--json] [--all]` +Сохранённые аккаунты пула ChatGPT доступны для просмотра даже на хостах без нативного каталога +Codex, в том числе в dashboard и при обновлении аккаунтов. Просмотр списка не создаёт каталог +Codex и не выполняет выход из нативного основного аккаунта. Отключение интеграции клиента Codex +не отключает наблюдение за квотой существующего нативного входа. Некорректный явно заданный +путь `CODEX_HOME` по-прежнему вызывает ошибку конфигурации. Небезопасное хранилище +`OPENCODEX_HOME` вызывает ошибку даже при отсутствии нативного каталога Codex. +При разрешении пути к каталогу Codex по умолчанию также возвращается ошибка, если он является файлом, ссылкой на отсутствующий объект или его родительский каталог не позволяет пройти по пути. + Без провайдера команда показывает пул Codex, OAuth-аккаунты и настроенные API-key pool'ы. Пустые провайдеры пропускаются, если не задан `--all`. С провайдером выводится только это семейство credential'ов. Human-output использует формат diff --git a/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md b/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md index acb18d2c2a1..184e193ca23 100644 --- a/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md @@ -175,6 +175,14 @@ kullanılamadığında atlanır): ### `ocx account list [provider] [--json] [--all]` +Kayıtlı ChatGPT havuz hesapları, yerel Codex ana dizini olmayan makinelerde de listelenebilir; +buna pano ve hesap yenileme işlemleri dahildir. Listeleme, Codex ana dizini oluşturmaz ve yerel ana +hesabın oturumunu kapatmaz. Codex istemci entegrasyonunu devre dışı bırakmak, mevcut bir yerel oturumun +kota gözlemini devre dışı bırakmaz. Açıkça belirtilmiş geçersiz `CODEX_HOME` yolları yine yapılandırma +hatası döndürür. Güvenli olmayan `OPENCODEX_HOME` depolaması, yerel Codex ana dizini eksik olsa bile +hata döndürür. Varsayılan Codex ana dizini yolu çözümlenirken dosyalar, hedefi bulunmayan sembolik +bağlantılar ve geçiş yapılamayan üst dizinler de hataya neden olur. + Bir sağlayıcı olmadan Codex havuzunu, OAuth hesaplarını ve yapılandırılmış API anahtarı havuzlarını listeler. `--all` bulunmadıkça boş sağlayıcılar atlanır. Bir sağlayıcı ile yalnızca bu kimlik bilgisi ailesini listeler. İnsan çıktısı diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md b/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md index 204e22636e0..d30ce4d9ea7 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md @@ -149,6 +149,12 @@ OAuth 账号会显示为 `Account N`,而 plan/label 列会在 plan、屏蔽后 ### `ocx account list [provider] [--json] [--all] [--quota [--refresh]]` +即使主机上没有原生 Codex 主目录,保存的 ChatGPT 池账号仍可列出,包括在仪表盘中和刷新账号时。 +查询列表不会创建 Codex 主目录,也不会退出原生主账号。禁用 Codex 客户端集成不会停止 +观测已有原生登录的配额;显式设置的 `CODEX_HOME` 路径无效时,仍会报告配置错误。 +如果 `OPENCODEX_HOME` 存储不安全,即使没有原生 Codex 主目录,也会报告错误。 +解析默认 Codex 主目录路径时,如果该路径是文件、目标不存在的符号链接,或父目录无法遍历,也会报告错误。 + 不指定提供方时,会列出 Codex 池、OAuth 账号和已配置的 API 密钥池。除非提供 `--all`,否则会跳过空的提供方。指定提供方时,只列出该凭据家族。人类可读输出 使用 `PROVIDER TYPE ID PLAN/LABEL PRIORITY STATUS`;手动选中的 Codex 行会标记为 `selected`。 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md b/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md index 1fcf6399416..e5e3c6dd68f 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md @@ -113,6 +113,11 @@ Codex 池選擇套用於清除既有親和性後的下一個請求;進行中 ### `ocx account list [provider] [--json] [--all] [--quota [--refresh]]` +即使主機沒有原生 Codex 主目錄,仍可列出已儲存的 ChatGPT 池帳號,包括在儀表板中及重新整理帳號時。 +列出帳號不會建立 Codex 主目錄,也不會將原生主帳號登出。停用 Codex 用戶端整合不會停用對既有原生登入的額度觀測; +明確設定但無效的 `CODEX_HOME` 路徑仍會回報設定錯誤。若 `OPENCODEX_HOME` 儲存空間不安全,即使原生 Codex 主目錄不存在,也會回報錯誤。 +解析預設 Codex 主目錄路徑時,若該路徑是檔案、目標不存在的符號連結,或父目錄無法遍歷,也會回報錯誤。 + 未指定供應商時,列出 Codex 池、OAuth 帳號與已設定的 API-key 池。除非存在 `--all`,否則空的供應商會被跳過。指定供應商時,僅列出該憑證家族。人類輸出使用 `PROVIDER TYPE ID PLAN/LABEL STATUS`;手動選擇的 Codex 列標記為 `selected`。當儲存了兩個以上符合資格的 Kiro 帳號時,預設情況下 429 會自動輪換至另一個帳號,並優先選擇已知剩餘額度最多的帳號;輪換由帳號存在與否驅動,且無法關閉;`oauthAccountFailover.enabled: false` 拒絕的是送出前的帳號優選,而非 429 復原。`ocx account login kiro` 每次將一個帳號加入池中。空結果仍為成功。`--json` 回傳: ```text diff --git a/scripts/test-layout/layout-codex-collision.json b/scripts/test-layout/layout-codex-collision.json index c4709d021d7..d35f56aaaf7 100644 --- a/scripts/test-layout/layout-codex-collision.json +++ b/scripts/test-layout/layout-codex-collision.json @@ -1,5 +1,6 @@ { "codex-stored-main-duplicate.test.ts": "codex-integration", + "codex-account-slot-identity.test.ts": "codex-integration", "codex-pool-caller-fallback-policy.test.ts": "codex-integration", "codex-windows-current-sid.test.ts": "codex-integration" } diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 329f5cb1306..8d1496c0842 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -65,7 +65,7 @@ "jev-decision-scope.test.ts": "server", "server-combo-plan-model-refusal.test.ts": "server", "codex-main-token-invalidated.test.ts": "codex-integration", - "codex-main-grant-refusal.test.ts": "codex-integration", + "codex-main-grant-refusal.test.ts": "codex-integration", "codex-auth-headless-accounts.test.ts": "codex-integration", "responses-alternate-main-cancellation.test.ts": "responses", "codex-shim-path-readiness.test.ts": "codex-integration", "codex-shim-fnm.test.ts": "codex-integration", @@ -466,7 +466,7 @@ "claude-system-env-auto.test.ts": "claude-integration", "cleanup-orphaned-workflows.test.ts": "ci-workflows", "clearable-deadline.test.ts": "lib", - "cli-account-alias-target.test.ts": "cli", + "cli-account-alias-target.test.ts": "cli", "cli-account-slot-identity.test.ts": "cli", "cli-account-cancel-flow.test.ts": "cli", "cli-account-kiro-device.test.ts": "cli", "cli-account-orca-import.test.ts": "cli", "cli-account-pin-drain.test.ts": "cli", diff --git a/src/cli/account-api.ts b/src/cli/account-api.ts index 6ea43d96d9c..7a009dcaefd 100644 --- a/src/cli/account-api.ts +++ b/src/cli/account-api.ts @@ -23,6 +23,9 @@ export interface AccountRow { provider: string; type: AccountType; id: string; + slotId?: string; + slotKind?: "native-main" | "pool"; + upstreamAccountKey?: string; label?: string; email?: string; /** @@ -329,6 +332,9 @@ export interface ProviderQuotaReportDto { interface CodexAccountDto { id: string; + slotId?: string; + slotKind?: "native-main" | "pool"; + upstreamAccountKey?: string; alias?: string; email?: string; plan?: string; @@ -401,6 +407,11 @@ export async function fetchCodexRows( provider: "openai", type: "codex" as const, id: a.id, + slotId: a.id, + slotKind: a.id === "__main__" ? "native-main" as const : "pool" as const, + ...(a.slotId === a.id && typeof a.upstreamAccountKey === "string" + && /^uai1_[0-9a-f]{64}$/.test(a.upstreamAccountKey) + ? { upstreamAccountKey: a.upstreamAccountKey } : {}), label: a.alias ?? a.plan ?? a.email, email: a.email, plan: a.plan, diff --git a/src/codex/auth-api/account-identity.ts b/src/codex/auth-api/account-identity.ts new file mode 100644 index 00000000000..53d8f296357 --- /dev/null +++ b/src/codex/auth-api/account-identity.ts @@ -0,0 +1,14 @@ +import { createHmac } from "node:crypto"; + +/** A local, versioned join key for two slots backed by the same upstream user. */ +export function codexUpstreamAccountKey( + adminToken: string | null, + accountId: string | null | undefined, + userId: string | null | undefined, +): string | undefined { + if (!adminToken || !accountId || !userId || accountId.length > 512 || userId.length > 512) return undefined; + return `uai1_${createHmac("sha256", adminToken) + .update("opencodex-upstream-account-v1\0") + .update(JSON.stringify([accountId, userId])) + .digest("hex")}`; +} diff --git a/src/codex/auth-api/account-list.ts b/src/codex/auth-api/account-list.ts index c3046363f1f..f97e58c5232 100644 --- a/src/codex/auth-api/account-list.ts +++ b/src/codex/auth-api/account-list.ts @@ -28,12 +28,16 @@ import type { OAuthAccountHealth, OAuthHealthLabel } from "../../oauth/health"; import { isSelectableCodexPoolAccount } from "../account-id"; import type { AdmissionLease } from "../../lib/admission"; import { tryAcquireNativeMainProfileClaim } from "../native-main-admission"; -import { withNativeMainCredentialClaim, isNativeMainClaimUnavailable } from "./http"; +import { withNativeMainCredentialClaim, isNativeMainClaimUnavailable, isNativeMainObservationUnavailable } from "./http"; import { fetchMainAccountInfoAttempt, EMPTY_MAIN_ACCOUNT_INFO, mainResetCreditsForCurrentIdentity } from "./main-account-probe"; import { fetchPoolAccountQuota, PoolQuotaProbeBusyError, POOL_QUOTA_REFRESH_CONCURRENCY } from "./pool-quota-probe"; import type { PoolQuotaResult } from "./pool-quota-probe"; import { getRuntimeConfig, configuredPoolAccount, mapWithConcurrency } from "./runtime-config"; import { isPassiveRead } from "../../lib/passive-read"; +import { configuredAdminToken } from "../../lib/admin-secrets"; +import { nativeUserIdClaims } from "../reserve-availability"; +import { extractAccountIdClaims } from "../../oauth/chatgpt"; +import { codexUpstreamAccountKey } from "./account-identity"; import { CODEX_MAIN_DUPLICATE_ACTION, configuredCodexMainDuplicate, getObservedMainCodexCollisionIdentity } from "../collision-identity"; export function quotaForPlan | StoredAccountQuota | null>( @@ -109,6 +113,7 @@ export function poolAccountDto( paused: boolean, priority: number, maskEmails: boolean, + upstreamAccountKey?: string, ): CodexAuthAccountDto { const plan = codexPlanValue(account.plan); const quota = quotaForPlan(quotaResult.quota, plan); @@ -136,6 +141,9 @@ export function poolAccountDto( const duplicateMain = configuredCodexMainDuplicate(config, account.id); return { id: account.id, + slotId: account.id, + slotKind: "pool", + ...(upstreamAccountKey ? { upstreamAccountKey } : {}), email: projectEmail(account.email, maskEmails) ?? account.email, ...(account.alias !== undefined ? { alias: account.alias } : {}), ...(plan !== undefined ? { plan } : {}), @@ -166,6 +174,11 @@ export function poolAccountDto( export interface CodexAuthAccountDto { id: string; + /** Registered slot identity; equal to id, including __main__ for native Codex. */ + slotId: string; + slotKind: "native-main" | "pool"; + /** Opaque local join key, present only with account and user claims. */ + upstreamAccountKey?: string; alias?: string; email: string; plan?: string | null; @@ -285,6 +298,7 @@ export async function listCodexAuthAccountsSnapshot( pruneCodexCredits([MAIN_CODEX_ACCOUNT_ID, ...poolAccounts.map(account => account.id)]); // One redaction decision for the whole snapshot, read once from the operator's config (#3859). const maskEmails = emailMaskingEnabled(runtimeConfig); + const identitySecret = configuredAdminToken(); // A passive read (a Remote Link Child's, #6959) reports what this machine already observed: no // token refresh, no WHAM probe, and so none of the plan or credential writes they make. const passive = isPassiveRead(); @@ -292,6 +306,7 @@ export async function listCodexAuthAccountsSnapshot( ? { info: getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: false, hasCredential: false, identityGeneration: captureMainAccountIdentityGeneration() } : await fetchMainAccountInfoAttempt(forceRefresh, 1, undefined, false, forceRefresh, false, runtimeConfig); + const observedMainIdentity = getObservedMainCodexCollisionIdentity(); const refreshedPool = await mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async account => { const cred = getCodexAccountCredential(account.id); let quotaResult: PoolQuotaResult; @@ -363,6 +378,10 @@ export async function listCodexAuthAccountsSnapshot( const dtoAccount = generationLive && quotaResult.freshPlan ? { ...currentAccount, plan: quotaResult.freshPlan } : currentAccount; + const user = nativeUserIdClaims(currentCredential.accessToken); + const accountClaim = extractAccountIdClaims(currentCredential.accessToken); + const verifiedAccountId = !accountClaim.conflict && accountClaim.accountId === currentCredential.chatgptAccountId + ? accountClaim.accountId : undefined; return [poolAccountDto( runtimeConfig, dtoAccount, @@ -371,6 +390,8 @@ export async function listCodexAuthAccountsSnapshot( isCodexAccountPaused(runtimeConfig, accountId), getCodexAccountPriority(runtimeConfig, accountId), maskEmails, + codexUpstreamAccountKey(identitySecret, verifiedAccountId, + user.conflict ? undefined : user.userId), )]; }); const projectSnapshot = (mainRefreshRefused = false): CodexAuthAccountsSnapshot => { @@ -402,8 +423,14 @@ export async function listCodexAuthAccountsSnapshot( ? mainResult.terminalAuthFailure === true && liveQuotaRefresh?.status === "http_error" ? "unauthorized" : "refresh_failed" : mainHealth.status === "reauth_required" ? mainHealth.reason : undefined; + const mainIdentity = mainSnapshotLive && getObservedMainCodexCollisionIdentity() === observedMainIdentity + ? observedMainIdentity : undefined; + const mainAccountKey = codexUpstreamAccountKey(identitySecret, mainIdentity?.accountId, mainIdentity?.userId); const main: CodexAuthAccountDto = { id: MAIN_CODEX_ACCOUNT_ID, + slotId: MAIN_CODEX_ACCOUNT_ID, + slotKind: "native-main", + ...(mainAccountKey ? { upstreamAccountKey: mainAccountKey } : {}), email: projectEmail(mainInfo.email, maskEmails) ?? "Codex App login", plan: mainInfo.plan, ...(liveQuotaRefresh ? { quotaRefresh: liveQuotaRefresh } : {}), @@ -442,7 +469,7 @@ export async function listCodexAuthAccountsSnapshot( try { return await withNativeMainCredentialClaim(async () => projectSnapshot(isMainAccountRefreshGrantRejected())); } catch (error) { - if (isNativeMainClaimUnavailable(error)) return projectSnapshot(); + if (isNativeMainObservationUnavailable(error)) return projectSnapshot(); throw error; } finally { projectionLease.release(); diff --git a/src/codex/auth-api/http.ts b/src/codex/auth-api/http.ts index 55c6983178a..61fe8c24199 100644 --- a/src/codex/auth-api/http.ts +++ b/src/codex/auth-api/http.ts @@ -1,3 +1,5 @@ +import { codexHomeIsAbsent } from "../codex-home-owner"; +import { getCodexHome } from "../paths"; import { withNativeMainSharedClaim } from "../native-main-claim"; import { resolveNativeProfileContext } from "../native-profile-store"; import { NativeProfileError } from "../native-profile-types"; @@ -7,6 +9,17 @@ export function isNativeMainClaimUnavailable(error: unknown): error is NativePro && (error.code === "NATIVE_MAIN_CLAIM_BUSY" || error.code === "NATIVE_MAIN_CLAIM_UNAVAILABLE"); } +/** Missing native homes leave reads unobserved; credential operations still require their claim. */ +export function isNativeMainObservationUnavailable(error: unknown): error is NativeProfileError { + if (isNativeMainClaimUnavailable(error)) return true; + if (!(error instanceof NativeProfileError) || error.code !== "CODEX_HOME_UNAVAILABLE") return false; + try { + return codexHomeIsAbsent(getCodexHome()); + } catch { + return false; + } +} + export function withNativeMainCredentialClaim(operation: () => Promise): Promise { return withNativeMainSharedClaim(resolveNativeProfileContext(), operation); } diff --git a/src/codex/auth-api/main-account-probe.ts b/src/codex/auth-api/main-account-probe.ts index a8ae999f06f..d3aebe4da53 100644 --- a/src/codex/auth-api/main-account-probe.ts +++ b/src/codex/auth-api/main-account-probe.ts @@ -23,7 +23,7 @@ import type { AdmissionLease } from "../../lib/admission"; import { nonEmptyPlan } from "./runtime-config"; import { tryAcquireNativeMainProfileClaim } from "../native-main-admission"; import { WHAM_REQUEST_TIMEOUT_MS } from "../quota-recovery-timing"; -import { withNativeMainCredentialClaim, isNativeMainClaimUnavailable } from "./http"; +import { withNativeMainCredentialClaim, isNativeMainObservationUnavailable } from "./http"; import { readMainAuthErrorCode, currentQuotaDispatchSequence, nextQuotaDispatchSequence, isQuotaDispatchCurrent, publishQuotaDispatch } from "./pool-quota-probe"; /** @@ -198,7 +198,7 @@ export async function fetchMainAccountInfoAttempt( try { return await withNativeMainCredentialClaim(operation); } catch (error) { - if (isNativeMainClaimUnavailable(error)) { + if (isNativeMainObservationUnavailable(error)) { return { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: false, diff --git a/src/codex/collision-identity.ts b/src/codex/collision-identity.ts index 69124e0df9e..9d899f945e8 100644 --- a/src/codex/collision-identity.ts +++ b/src/codex/collision-identity.ts @@ -1,12 +1,14 @@ -import { extractAccountIdClaims, extractEmail } from "../oauth/chatgpt"; +import { decodeJwtPayload, extractAccountIdClaims, extractEmail } from "../oauth/chatgpt"; import type { CodexAccount, OcxConfig } from "../types"; import { getCodexAccountCredential } from "./account-store"; import type { CodexTokens } from "./auth-collision"; import { captureMainAccountIdentityGeneration, isMainAccountIdentityGenerationLive } from "./main-account-cache"; import { codexPlanKey, extractChatgptPlanType } from "./plan"; +import { nativeUserIdClaims } from "./reserve-availability"; export interface CodexCollisionIdentity { accountId: string; + userId?: string; email?: string | null; plan?: unknown; } @@ -34,19 +36,42 @@ function homeScope(): string { let observed: { identity: CodexCollisionIdentity; homeScope: string; generation: number } | undefined; +function suppressObservedUserId(): void { + if (!observed || observed.homeScope !== homeScope() + || !isMainAccountIdentityGenerationLive(observed.generation)) return; + observed = { ...observed, identity: { ...observed.identity, userId: undefined } }; +} + /** Only existing owned lifecycle observations may publish; never call with a request bearer. */ export function observeMainCodexCollisionIdentity(tokens?: CodexTokens): void { if (!tokens) { observed = undefined; return; } if (typeof tokens.access_token !== "string" || typeof tokens.account_id !== "string" - || (tokens.id_token !== undefined && typeof tokens.id_token !== "string")) return; + || (tokens.id_token !== undefined && typeof tokens.id_token !== "string")) { + suppressObservedUserId(); + return; + } const claims = [extractAccountIdClaims(tokens.id_token), extractAccountIdClaims(tokens.access_token)]; - if (claims.some(claim => claim.conflict)) return; + if (claims.some(claim => claim.conflict)) { suppressObservedUserId(); return; } const accountId = tokens.account_id || claims.find(claim => claim.accountId)?.accountId; - if (!accountId || claims.some(claim => claim.accountId && claim.accountId !== accountId)) return; + if (!accountId || claims.some(claim => claim.accountId && claim.accountId !== accountId)) { + suppressObservedUserId(); + return; + } const emails = new Set([extractEmail(tokens.id_token), extractEmail(undefined, tokens.access_token)] .flatMap(email => normalizedEmail(email) ? [normalizedEmail(email)!] : [])); - if (emails.size > 1) return; + if (emails.size > 1) { suppressObservedUserId(); return; } + const idPayload: unknown = tokens.id_token === undefined ? undefined : decodeJwtPayload(tokens.id_token); + const idTokenMalformed = tokens.id_token !== undefined + && (idPayload === null || typeof idPayload !== "object" || Array.isArray(idPayload)); + const users = [tokens.id_token ? nativeUserIdClaims(tokens.id_token) : undefined, nativeUserIdClaims(tokens.access_token)]; + const userIds = new Set(users.flatMap(user => user?.userId ? [user.userId] : [])); + // A join key needs one token claiming both the account and the user, as a Pool access token does; + // auth.json account_id alone, or claims split across a mixed token set, prove no pairing. + const bound = claims.some((claim, index) => claim.accountId === accountId && users[index]?.userId); + const userId = !idTokenMalformed && !users.some(user => user?.conflict) && bound && userIds.size === 1 + ? [...userIds][0] : undefined; observed = { identity: { accountId, email: [...emails][0], + ...(userId ? { userId } : {}), plan: extractChatgptPlanType(tokens.id_token, tokens.access_token) }, homeScope: homeScope(), generation: captureMainAccountIdentityGeneration() }; } diff --git a/src/codex/native-profile-store.ts b/src/codex/native-profile-store.ts index 7ab797761d6..ae798a55184 100644 --- a/src/codex/native-profile-store.ts +++ b/src/codex/native-profile-store.ts @@ -353,8 +353,9 @@ export function assertNativeProfileLockPath(context: NativeProfileContext): void } export function resolveNativeProfileContext(options: { codexHome?: string; configDir?: string } = {}): NativeProfileContext { - const codexHome = canonicalExistingDirectory(options.codexHome ?? getCodexHome(), "The effective CODEX_HOME"); + // Read-only callers may tolerate an absent native home, but never unsafe OpenCodex storage. const configDir = canonicalizeDirectoryPath(options.configDir ?? getConfigDir()); + const codexHome = canonicalExistingDirectory(options.codexHome ?? getCodexHome(), "The effective CODEX_HOME"); const homeId = nativeProfileHomeId(codexHome); const foldedHomeId = process.platform === "win32" ? legacyWindowsNativeProfileHomeId(codexHome) : homeId; const instanceId = createHash("sha256").update(DOMAIN_INSTANCE).update(configDir).digest("hex"); diff --git a/structure/providers/openai-accounts.md b/structure/providers/openai-accounts.md index f9c7131904c..961cee4f8ac 100644 --- a/structure/providers/openai-accounts.md +++ b/structure/providers/openai-accounts.md @@ -37,6 +37,7 @@ preserving a stale one would block every later migration. ## Model and wire identity +Account-list rows retain each registered `slotId` and `slotKind`; optional `upstreamAccountKey` is a versioned HMAC of the ChatGPT account ID and user claim under the local administrator token. Native identity comes from the owned observation without passive credential reads; missing or conflicting claims omit the key, the native key needs one token that claims both the matching account and the user (an `auth.json` `account_id` alone, or account and user claims split across the ID and access tokens, omit it), Pool rows need the access token's matching account claim, and token rotation changes it. No raw identity or credential is serialized. The optional [native reasoning retention policy](../transports/native-reasoning-retention.md#runtime-boundary) can permit reasoning ciphertext forwarding across a known model or account change on the same canonical ChatGPT destination. It does not change account selection, entitlement, cache isolation, continuation-id ownership or uploaded-file retention; cross-account acceptance is not guaranteed. Native Spark membership and its model-specific request/tool exceptions are removed; the shared @@ -250,9 +251,8 @@ restored from the Accounts picker or Codex Auth through gated recovery: missing from the canonical preset, disabled canonical rows are re-enabled without replacing saved mode or model settings, and noncanonical `openai` rows never receive that recovery path. -`GET /api/codex-auth/accounts?refresh=1` treats missing main credentials, HTTP 401, and allowlisted -terminal 403 codes as `needsReauth`; generic permission failures remain non-terminal, and a -successful main usage refresh clears the runtime mark. +Account listing and explicit refresh return stored pool accounts without creating a missing native `CODEX_HOME`; an unchecked main observation leaves credential/reauth state unchanged. When the native home is accessible, `GET /api/codex-auth/accounts?refresh=1` treats missing main credentials, HTTP 401 responses with a known terminal code, unrecognized HTTP 401 responses when the access token is not verifiably live, and allowlisted terminal 403 responses as `needsReauth`; successful refresh clears the runtime mark. +Generic quota permission failures remain non-terminal; missing-home fallback requires proven absence, so non-directory homes, dangling links, and parent directories that cannot be traversed still fail closed for both default and explicit home paths. Native-profile context resolution validates OpenCodex storage before the native home, so unsafe storage fails closed even when that home is absent; credential operations retain their strict claim requirements. `src/codex/chatgpt-refresh-failure.ts` classifies token-endpoint failures for both stored-pool and native-main refresh. Exact structured terminal codes apply only to authentication-error diff --git a/tests/cli/cli-account-slot-identity.test.ts b/tests/cli/cli-account-slot-identity.test.ts new file mode 100644 index 00000000000..3ae3544d861 --- /dev/null +++ b/tests/cli/cli-account-slot-identity.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "bun:test"; +import { cmdAccount } from "../../src/cli/account"; +import type { AccountDeps } from "../../src/cli/account-api"; +import type { OcxConfig } from "../../src/types"; + +/** + * `ocx account list --json` carries the slot identity contract from the account-list API. + * Kept out of the #180 matrix file, which sits at its file-size ratchet cap. + */ +function deps(accounts: Array>): AccountDeps { + return { + baseUrl: "http://127.0.0.1:10100", + loadConfigImpl: () => ({ + port: 10100, + defaultProvider: "openai", + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", codexAccountMode: "pool" } }, + }) as unknown as OcxConfig, + fetchImpl: (async (url: string | URL | Request) => { + const path = new URL(String(url)).pathname; + if (path === "/api/codex-auth/accounts") return Response.json({ accounts }); + if (path === "/api/codex-auth/active") return Response.json({ activeCodexAccountId: "__main__" }); + return Response.json({}); + }) as unknown as typeof fetch, + }; +} + +async function runJson(args: string[], accounts: Array>) { + const lines: string[] = []; + const log = console.log; + console.log = (...parts: unknown[]) => { lines.push(parts.map(String).join(" ")); }; + try { + const code = await cmdAccount(args, deps(accounts)); + return { code, rows: (JSON.parse(lines.join("\n")) as { accounts: Array> }).accounts }; + } finally { + console.log = log; + } +} + +describe("ocx account list: slot identity", () => { + test("official --json output carries the upstream join contract", async () => { + const key = `uai1_${"a".repeat(64)}`; + const result = await runJson(["list", "openai", "--json"], [ + { id: "__main__", slotId: "__main__", slotKind: "native-main", upstreamAccountKey: key, + email: "main@example.test", plan: "pro" }, + { id: "chatgpt_1", slotId: "chatgpt_1", slotKind: "pool", upstreamAccountKey: key, + email: "pool@example.test", plan: "pro" }, + ]); + expect(result.code).toBe(0); + expect(result.rows.filter(row => row.provider === "openai") + .map(row => [row.slotId, row.slotKind, row.upstreamAccountKey])).toEqual([ + ["__main__", "native-main", key], + ["chatgpt_1", "pool", key], + ]); + }); +}); diff --git a/tests/cli/cli-dto-fidelity.test.ts b/tests/cli/cli-dto-fidelity.test.ts index bae0bff7d36..468df021518 100644 --- a/tests/cli/cli-dto-fidelity.test.ts +++ b/tests/cli/cli-dto-fidelity.test.ts @@ -193,6 +193,35 @@ describe("#2703 the projection does not strip the 5h window", () => { }); }); +describe("Codex account identity projection", () => { + test("keeps validated join keys and slot identities while accepting older servers", async () => { + const key = `uai1_${"a".repeat(64)}`; + const fetchImpl = (async (url: string | URL | Request) => Response.json( + String(url).includes("/api/codex-auth/active") + ? { activeCodexAccountId: "__main__" } + : { accounts: [ + { id: "__main__", slotId: "__main__", slotKind: "native-main", upstreamAccountKey: key }, + { id: "pool-a", slotId: "pool-a", slotKind: "pool", upstreamAccountKey: key }, + { id: "pool-b", slotId: "other-slot", upstreamAccountKey: key }, + { id: "pool-legacy" }, + { id: "pool-object", slotId: "pool-object", upstreamAccountKey: { toString: null, valueOf: null } }, + { id: "pool-array", slotId: "pool-array", upstreamAccountKey: [key] }, + ] }, + )) as unknown as typeof fetch; + const { fetchCodexRows } = await import("../../src/cli/account-api"); + const result = await fetchCodexRows({ baseUrl: "http://127.0.0.1:10100", fetchImpl }, + "http://127.0.0.1:10100"); + expect(result.rows.map(row => [row.slotId, row.slotKind, row.upstreamAccountKey])).toEqual([ + ["__main__", "native-main", key], + ["pool-a", "pool", key], + ["pool-b", "pool", undefined], + ["pool-legacy", "pool", undefined], + ["pool-object", "pool", undefined], + ["pool-array", "pool", undefined], + ]); + }); +}); + describe("#2705 access key usage columns", () => { async function listOutput(payload: Record): Promise { const { handleAccessCommand } = await import("../../src/cli/access"); diff --git a/tests/codex-integration/codex-account-slot-identity.test.ts b/tests/codex-integration/codex-account-slot-identity.test.ts new file mode 100644 index 00000000000..e62a1f0f807 --- /dev/null +++ b/tests/codex-integration/codex-account-slot-identity.test.ts @@ -0,0 +1,174 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveCodexAccountCredential } from "../../src/codex/account-store"; +import { codexAccountUnusableReason } from "../../src/codex/account-usability"; +import { reconcileMainCodexAccountRuntimeState, resetMainCodexAccountIdentityTrackingForTests } from "../../src/codex/account-lifecycle"; +import type { CodexAuthAccountDto } from "../../src/codex/auth-api/account-list"; +import { handleCodexAuthAPI } from "../../src/codex/auth-api/routes"; +import { clearMainAccountInfoCache } from "../../src/codex/main-account-cache"; +import { setMainAccountPlan } from "../../src/codex/main-account"; +import { clearAccountQuota } from "../../src/codex/quota"; +import { clearCodexUpstreamHealth, clearThreadAccountMap } from "../../src/codex/routing"; +import { runPassiveRead } from "../../src/lib/passive-read"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +/** + * Security proof for the slot identity fields on the account-list API: `slotId`, `slotKind` + * and the opaque `upstreamAccountKey` must carry no upstream account ID, user ID, token, or the + * local admin secret that keys the join. Every sentinel below is distinctive so a substring + * search over the serialized response is a meaningful check. + */ +const ACCOUNT = "acct-sentinel-0f3a9d"; +const USER = "user-sentinel-8c1d42"; +const EMAIL = "slot-proof@example.test"; +const POOL_ID = "slot-proof-pool"; +const NATIVE_REFRESH = "native-refresh-sentinel-5b7e"; +const POOL_REFRESH = "pool-refresh-sentinel-2e9c"; +const ADMIN_TOKEN = `ocx_admin_${"Q7".repeat(21)}Z`; +const ROTATED_ADMIN_TOKEN = `ocx_admin_${"R4".repeat(21)}Y`; +const KEY_FORMAT = /^uai1_[0-9a-f]{64}$/; + +let home: string; +let saved: Record<"CODEX_HOME" | "OPENCODEX_HOME" | "OPENCODEX_ADMIN_AUTH_TOKEN", string | undefined>; +let originalFetch: typeof fetch; +let config: OcxConfig; + +function jwt(options: { account?: string; user?: string; label: string }): string { + return `header.${Buffer.from(JSON.stringify({ + exp: Math.floor(Date.now() / 1000) + 3600, + ...(options.account ? { chatgpt_account_id: options.account } : {}), + "https://api.openai.com/auth": { chatgpt_plan_type: "plus", ...(options.user ? { chatgpt_user_id: options.user } : {}) }, + "https://api.openai.com/profile": { email: EMAIL }, + jti: options.label, + })).toString("base64url")}.sig-${options.label}`; +} + +function writeAdminToken(token: string): void { + writeFileSync(join(process.env.OPENCODEX_HOME!, "admin-api-token"), `${token}\n`); +} + +function writeNative(tokens: Record): void { + writeFileSync(join(process.env.CODEX_HOME!, "auth.json"), JSON.stringify({ tokens })); + reconcileMainCodexAccountRuntimeState(); +} + +function savePool(accessToken: string): void { + saveCodexAccountCredential(POOL_ID, { accessToken, refreshToken: POOL_REFRESH, + chatgptAccountId: ACCOUNT, expiresAt: Date.now() + 3_600_000 }); +} + +async function listOverHttp(): Promise<{ body: string; accounts: CodexAuthAccountDto[] }> { + const url = new URL("http://localhost/api/codex-auth/accounts"); + const response = await runPassiveRead(() => handleCodexAuthAPI(new Request(url), url, config)); + expect(response?.status).toBe(200); + const body = await response!.text(); + return { body, accounts: (JSON.parse(body) as { accounts: CodexAuthAccountDto[] }).accounts }; +} + +function resetRuntime(): void { + clearThreadAccountMap(); + clearCodexUpstreamHealth(); + clearAccountQuota(); + clearMainAccountInfoCache(); + setMainAccountPlan(null); + resetMainCodexAccountIdentityTrackingForTests(); +} + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-slot-identity-")); + saved = { CODEX_HOME: process.env.CODEX_HOME, OPENCODEX_HOME: process.env.OPENCODEX_HOME, + OPENCODEX_ADMIN_AUTH_TOKEN: process.env.OPENCODEX_ADMIN_AUTH_TOKEN }; + process.env.CODEX_HOME = join(home, "codex"); + process.env.OPENCODEX_HOME = join(home, "ocx"); + delete process.env.OPENCODEX_ADMIN_AUTH_TOKEN; + mkdirSync(process.env.CODEX_HOME, { recursive: true }); + mkdirSync(process.env.OPENCODEX_HOME, { recursive: true }); + originalFetch = globalThis.fetch; + // A passive read must not probe upstream; any request here is a test failure. + globalThis.fetch = (async () => { throw new Error("unexpected network request"); }) as unknown as typeof fetch; + config = { port: 10100, providers: {}, defaultProvider: "openai", codexMainAccountHardLock: false, + codexAccounts: [{ id: POOL_ID, email: EMAIL, plan: "plus", isMain: false }] }; + resetRuntime(); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + resetRuntime(); + for (const [name, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + removeTreeWithRetry(home); +}); + +describe("Codex account slot identity metadata", () => { + test("the account-list response carries no upstream identifiers, tokens, or admin secret", async () => { + writeAdminToken(ADMIN_TOKEN); + const nativeAccess = jwt({ account: ACCOUNT, user: USER, label: "native-access" }); + const nativeId = jwt({ account: ACCOUNT, user: USER, label: "native-id" }); + const poolAccess = jwt({ account: ACCOUNT, user: USER, label: "pool-access" }); + writeNative({ access_token: nativeAccess, id_token: nativeId, refresh_token: NATIVE_REFRESH, account_id: ACCOUNT }); + savePool(poolAccess); + + const { body, accounts } = await listOverHttp(); + const main = accounts.find(row => row.id === "__main__")!; + const pool = accounts.find(row => row.id === POOL_ID)!; + expect(main).toMatchObject({ slotId: "__main__", slotKind: "native-main" }); + expect(pool).toMatchObject({ slotId: POOL_ID, slotKind: "pool" }); + expect(main.upstreamAccountKey).toMatch(KEY_FORMAT); + expect(pool.upstreamAccountKey).toBe(main.upstreamAccountKey); + + const secrets = [ACCOUNT, USER, NATIVE_REFRESH, POOL_REFRESH, ADMIN_TOKEN, nativeAccess, nativeId, poolAccess, + ...[nativeAccess, nativeId, poolAccess].flatMap(token => token.split(".").slice(1))]; + for (const secret of secrets) expect(body).not.toContain(secret); + + // The key is keyed by the local admin secret: rotating it changes every key, so a holder of + // the identifiers alone cannot compute or confirm one. + writeAdminToken(ROTATED_ADMIN_TOKEN); + const rotated = await listOverHttp(); + const rotatedMain = rotated.accounts.find(row => row.id === "__main__")!; + expect(rotatedMain.upstreamAccountKey).toMatch(KEY_FORMAT); + expect(rotatedMain.upstreamAccountKey).not.toBe(main.upstreamAccountKey); + expect(rotated.body).not.toContain(ROTATED_ADMIN_TOKEN); + }); + + test("a native account_id without any token account claim yields no join key", async () => { + writeAdminToken(ADMIN_TOKEN); + writeNative({ access_token: jwt({ user: USER, label: "native-unclaimed" }), account_id: ACCOUNT }); + savePool(jwt({ account: ACCOUNT, user: USER, label: "pool-claimed" })); + + const { accounts } = await listOverHttp(); + expect(accounts.find(row => row.id === "__main__")).toMatchObject({ slotId: "__main__", slotKind: "native-main" }); + expect(accounts.find(row => row.id === "__main__")?.upstreamAccountKey).toBeUndefined(); + expect(accounts.find(row => row.id === POOL_ID)?.upstreamAccountKey).toMatch(KEY_FORMAT); + // The unverified account ID still guards against storing the native login as a Pool duplicate. + expect(codexAccountUnusableReason(config, POOL_ID)).toBe("duplicate_main"); + }); + + test("account and user claims split across native tokens yield no join key", async () => { + writeAdminToken(ADMIN_TOKEN); + // A stale or mixed token set: the ID token proves only the account, the access token only the user. + writeNative({ id_token: jwt({ account: ACCOUNT, label: "native-id-account-only" }), + access_token: jwt({ user: USER, label: "native-access-user-only" }), account_id: ACCOUNT }); + savePool(jwt({ account: ACCOUNT, user: USER, label: "pool-claimed" })); + + const { accounts } = await listOverHttp(); + expect(accounts.find(row => row.id === "__main__")?.upstreamAccountKey).toBeUndefined(); + expect(accounts.find(row => row.id === POOL_ID)?.upstreamAccountKey).toMatch(KEY_FORMAT); + }); + + test("one native token claiming both account and user still joins the Pool row", async () => { + writeAdminToken(ADMIN_TOKEN); + writeNative({ id_token: jwt({ account: ACCOUNT, user: USER, label: "native-id-bound" }), + access_token: jwt({ user: USER, label: "native-access-user" }), account_id: ACCOUNT }); + savePool(jwt({ account: ACCOUNT, user: USER, label: "pool-claimed" })); + + const { accounts } = await listOverHttp(); + const mainKey = accounts.find(row => row.id === "__main__")?.upstreamAccountKey; + expect(mainKey).toMatch(KEY_FORMAT); + expect(accounts.find(row => row.id === POOL_ID)?.upstreamAccountKey).toBe(mainKey); + }); +}); diff --git a/tests/codex-integration/codex-auth-headless-accounts.test.ts b/tests/codex-integration/codex-auth-headless-accounts.test.ts new file mode 100644 index 00000000000..d5d1c815298 --- /dev/null +++ b/tests/codex-integration/codex-auth-headless-accounts.test.ts @@ -0,0 +1,277 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getCodexAccountCredential, saveCodexAccountCredential } from "../../src/codex/account-store"; +import { resetMainCodexAccountIdentityTrackingForTests } from "../../src/codex/account-lifecycle"; +import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "../../src/codex/account-runtime-state"; +import type { CodexAuthAccountDto } from "../../src/codex/auth-api/account-list"; +import { withNativeMainCredentialClaim } from "../../src/codex/auth-api/http"; +import { handleCodexAuthAPI } from "../../src/codex/auth-api/routes"; +import * as codexHome from "../../src/codex/home"; +import { + captureMainAccountIdentityGeneration, clearMainAccountInfoCache, + getMainAccountCredentialPresence, getMainAccountInfoCache, +} from "../../src/codex/main-account-cache"; +import { MAIN_CODEX_ACCOUNT_ID } from "../../src/codex/main-account"; +import { getCodexHome } from "../../src/codex/paths"; +import { clearAccountQuota } from "../../src/codex/quota"; +import { resetQuotaQueryBackoffForTests } from "../../src/codex/quota-query-backoff"; +import { resetLifecycleDrainStateForTests } from "../../src/server/lifecycle"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +let root: string; +let nativeHome: string; +let previousOcxHome: string | undefined; +let previousCodexHome: string | undefined; +let defaultHomeSpy: { mockRestore(): void } | undefined; +let originalFetch: typeof fetch; +const poolId = "headless-pool"; +const originalDefaultCodexHome = codexHome.defaultCodexHome; + +function isolateDefaultHome(userHome: string): void { + // Keep real filesystem resolution while excluding unrelated Windows homes on WSL. + defaultHomeSpy = spyOn(codexHome, "defaultCodexHome").mockImplementation(() => originalDefaultCodexHome({ + homedir: () => userHome, env: {}, procVersion: "", release: "", + })); +} + +function config(codexIntegration = false): OcxConfig { + return { + port: 10100, defaultProvider: "openai", providers: {}, + clientIntegrations: { codex: codexIntegration }, + codexAccounts: [{ id: poolId, email: "pool@example.test", plan: "plus", isMain: false }], + }; +} + +function savePool() { + saveCodexAccountCredential(poolId, { + accessToken: "fixture-pool-access", refreshToken: "fixture-pool-refresh", + chatgptAccountId: "fixture-pool-identity", expiresAt: Date.now() + 3_600_000, + }); + return getCodexAccountCredential(poolId); +} + +function usageResponse(): Response { + return Response.json({ + plan_type: "plus", + rate_limit: { primary_window: { used_percent: 17, limit_window_seconds: 18_000 } }, + }); +} + +async function listAccounts(cfg: OcxConfig, method = "GET", suffix = "") { + const url = new URL(`http://localhost/api/codex-auth/accounts${suffix}`); + const response = await handleCodexAuthAPI(new Request(url, { method }), url, cfg); + expect(response?.status).toBe(200); + return (await response!.json() as { accounts: CodexAuthAccountDto[] }).accounts; +} + +function resetRuntimeState() { + clearMainAccountInfoCache(); + clearAccountQuota(); + clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + clearAccountNeedsReauth(poolId); + resetMainCodexAccountIdentityTrackingForTests(); + resetQuotaQueryBackoffForTests(); + resetLifecycleDrainStateForTests(); +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-headless-accounts-")); + nativeHome = join(root, "missing-codex-home"); + previousOcxHome = process.env.OPENCODEX_HOME; + previousCodexHome = process.env.CODEX_HOME; + process.env.OPENCODEX_HOME = join(root, "ocx"); + process.env.CODEX_HOME = nativeHome; + mkdirSync(process.env.OPENCODEX_HOME); + originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (url.origin !== "https://chatgpt.com" || !url.pathname.endsWith("/wham/usage")) { + throw new Error("unexpected fixture network request"); + } + return usageResponse(); + }) as typeof fetch; + resetRuntimeState(); +}); + +afterEach(() => { + defaultHomeSpy?.mockRestore(); + defaultHomeSpy = undefined; + globalThis.fetch = originalFetch; + resetRuntimeState(); + if (previousOcxHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOcxHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + removeTreeWithRetry(root); +}); + +describe("account listing without a native Codex home", () => { + for (const integration of [false, true]) { + for (const [method, suffix] of [["GET", ""], ["GET", "?refresh=1"], ["POST", "/refresh"]]) { + test(`${method} accounts${suffix}, integration=${integration}: stored pool remains available`, async () => { + const credential = savePool(); + const accounts = await listAccounts(config(integration), method, suffix); + expect(accounts).toHaveLength(2); + expect(accounts.find(account => account.id === poolId)).toMatchObject({ + hasCredential: true, needsReauth: false, plan: "plus", quota: { shortPercent: 17 }, + }); + expect(accounts.find(account => account.id === MAIN_CODEX_ACCOUNT_ID)).toMatchObject({ + hasCredential: false, needsReauth: false, quota: null, + }); + expect(isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)).toBe(false); + expect(getCodexAccountCredential(poolId)).toEqual(credential); + expect(existsSync(nativeHome)).toBe(false); + }); + } + } + + test("an explicit non-directory CODEX_HOME still refuses account listing", async () => { + writeFileSync(nativeHome, "fixture-not-a-directory"); + const credential = savePool(); + await expect(listAccounts(config())).rejects.toThrow("not a directory"); + expect(getCodexAccountCredential(poolId)).toEqual(credential); + expect(readFileSync(nativeHome, "utf8")).toBe("fixture-not-a-directory"); + }); + + for (const [method, suffix] of [["GET", ""], ["GET", "?refresh=1"], ["POST", "/refresh"]]) { + for (const kind of ["missing", "file", "dangling-link"] as const) { + test(`${method} accounts${suffix}: default home ${kind} only tolerates proven absence`, async () => { + const userHome = join(root, "user"); + mkdirSync(userHome); + isolateDefaultHome(userHome); + delete process.env.CODEX_HOME; + nativeHome = join(userHome, ".codex"); + if (kind === "file") writeFileSync(nativeHome, "fixture-not-a-directory"); + if (kind === "dangling-link") symlinkSync(join(root, "missing-target"), nativeHome, "junction"); + expect(getCodexHome()).toBe(nativeHome); + const credential = savePool(); + + if (kind === "missing") { + const accounts = await listAccounts(config(), method, suffix); + expect(accounts.find(account => account.id === poolId)?.hasCredential).toBe(true); + expect(existsSync(nativeHome)).toBe(false); + } else { + await expect(listAccounts(config(), method, suffix)).rejects.toMatchObject({ code: "CODEX_HOME_UNAVAILABLE" }); + if (kind === "file") expect(readFileSync(nativeHome, "utf8")).toBe("fixture-not-a-directory"); + else expect(lstatSync(nativeHome).isSymbolicLink()).toBe(true); + } + expect(getCodexAccountCredential(poolId)).toEqual(credential); + expect(isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)).toBe(false); + }); + } + } + + test.skipIf(process.platform === "win32" || process.getuid?.() === 0)("an inaccessible default home is not treated as absent", async () => { + const userHome = join(root, "unreadable-user"); + mkdirSync(userHome); + nativeHome = join(userHome, ".codex"); + mkdirSync(nativeHome); + isolateDefaultHome(userHome); + delete process.env.CODEX_HOME; + const credential = savePool(); + chmodSync(userHome, 0o000); + try { + expect(getCodexHome()).toBe(nativeHome); + await expect(listAccounts(config())).rejects.toMatchObject({ code: "CODEX_HOME_UNAVAILABLE" }); + expect(getCodexAccountCredential(poolId)).toEqual(credential); + expect(isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)).toBe(false); + } finally { + chmodSync(userHome, 0o700); + } + }); + + test("a home removed during pool refresh does not fail final account projection", async () => { + mkdirSync(nativeHome); + savePool(); + globalThis.fetch = (async () => { + removeTreeWithRetry(nativeHome); + return usageResponse(); + }) as typeof fetch; + const accounts = await listAccounts(config()); + expect(accounts.find(account => account.id === poolId)).toMatchObject({ + hasCredential: true, needsReauth: false, quota: { shortPercent: 17 }, + }); + expect(existsSync(nativeHome)).toBe(false); + }); + + test("disabled client integration still observes an existing native login", async () => { + mkdirSync(nativeHome); + const auth = JSON.stringify({ tokens: { + access_token: "fixture-main-access", account_id: "fixture-main-identity", + } }); + writeFileSync(join(nativeHome, "auth.json"), auth); + savePool(); + const accounts = await listAccounts(config()); + expect(accounts.find(account => account.id === MAIN_CODEX_ACCOUNT_ID)).toMatchObject({ + hasCredential: true, needsReauth: false, plan: "plus", quota: { shortPercent: 17 }, + }); + expect(readFileSync(join(nativeHome, "auth.json"), "utf8")).toBe(auth); + }); + + test("a missing home preserves previously observed identity and reauth state", async () => { + mkdirSync(nativeHome); + writeFileSync(join(nativeHome, "auth.json"), JSON.stringify({ tokens: { + access_token: "fixture-main-access", account_id: "fixture-main-identity", + } })); + savePool(); + await listAccounts(config()); + markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + const generation = captureMainAccountIdentityGeneration(); + const info = getMainAccountInfoCache(); + expect(info?.quota?.shortPercent).toBe(17); + expect(getMainAccountCredentialPresence()).toBe(true); + removeTreeWithRetry(nativeHome); + + const accounts = await listAccounts(config(), "GET", "?refresh=1"); + expect(accounts.find(account => account.id === MAIN_CODEX_ACCOUNT_ID)).toMatchObject({ + hasCredential: true, needsReauth: true, quota: null, + }); + expect(accounts.find(account => account.id === poolId)).toMatchObject({ + hasCredential: true, needsReauth: false, quota: { shortPercent: 17 }, + }); + expect(captureMainAccountIdentityGeneration()).toBe(generation); + expect(getMainAccountInfoCache()).toEqual(info); + expect(getMainAccountCredentialPresence()).toBe(true); + expect(isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)).toBe(true); + expect(existsSync(nativeHome)).toBe(false); + }); + + for (const homeExists of [true, false]) { + test(`an unsafe OpenCodex storage root refuses account listing, native home exists=${homeExists}`, async () => { + if (homeExists) mkdirSync(nativeHome); + const unsafeRoot = join(root, "not-a-config-directory"); + writeFileSync(unsafeRoot, "fixture-unsafe-root"); + process.env.OPENCODEX_HOME = unsafeRoot; + await expect(listAccounts(config())).rejects.toMatchObject({ code: "PROFILE_STORAGE_UNSAFE" }); + expect(readFileSync(unsafeRoot, "utf8")).toBe("fixture-unsafe-root"); + expect(existsSync(nativeHome)).toBe(homeExists); + }); + } + + test("a home removed during refresh does not hide an unsafe storage root at projection", async () => { + mkdirSync(nativeHome); + savePool(); + const unsafeRoot = join(root, "not-a-config-directory"); + writeFileSync(unsafeRoot, "fixture-unsafe-root"); + globalThis.fetch = (async () => { + removeTreeWithRetry(nativeHome); + process.env.OPENCODEX_HOME = unsafeRoot; + return usageResponse(); + }) as typeof fetch; + await expect(listAccounts(config())).rejects.toMatchObject({ code: "PROFILE_STORAGE_UNSAFE" }); + expect(readFileSync(unsafeRoot, "utf8")).toBe("fixture-unsafe-root"); + expect(existsSync(nativeHome)).toBe(false); + }); + + test("credential operations still refuse a missing native home before executing", async () => { + const mutationPath = join(root, "must-not-be-written"); + await expect((async () => withNativeMainCredentialClaim(async () => { + writeFileSync(mutationPath, "unexpected mutation"); + }))()).rejects.toMatchObject({ code: "CODEX_HOME_UNAVAILABLE" }); + expect(existsSync(mutationPath)).toBe(false); + expect(existsSync(nativeHome)).toBe(false); + }); +}); diff --git a/tests/codex-integration/codex-stored-main-duplicate.test.ts b/tests/codex-integration/codex-stored-main-duplicate.test.ts index 4082e770537..daba4f87eaf 100644 --- a/tests/codex-integration/codex-stored-main-duplicate.test.ts +++ b/tests/codex-integration/codex-stored-main-duplicate.test.ts @@ -14,6 +14,7 @@ import { clearThreadAccountMap, clearCodexUpstreamHealth } from "../../src/codex import { clearAccountQuota } from "../../src/codex/quota"; import { clearMainAccountInfoCache } from "../../src/codex/main-account-cache"; import { setMainAccountPlan } from "../../src/codex/main-account"; +import { observeMainCodexCollisionIdentity } from "../../src/codex/collision-identity"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -23,16 +24,18 @@ let previousOcxHome: string | undefined; let config: OcxConfig; const poolId = "stored-main-duplicate"; const email = "member@example.test"; -function jwt(plan: string, member?: string): string { +function jwt(plan: string, member?: string, userId?: string, accountId = "shared-account"): string { return `header.${Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 3600, - "https://api.openai.com/auth": { chatgpt_plan_type: plan }, + chatgpt_account_id: accountId, + "https://api.openai.com/auth": { chatgpt_plan_type: plan, ...(userId ? { chatgpt_user_id: userId } : {}) }, ...(member ? { "https://api.openai.com/profile": { email: member } } : {}), })).toString("base64url")}.fake`; } -function native(accountId = "shared-account", plan = "plus", member: string | undefined = email, observe = true): void { +function native(accountId = "shared-account", plan = "plus", member: string | undefined = email, + observe = true, userId?: string): void { writeFileSync(join(process.env.CODEX_HOME!, "auth.json"), JSON.stringify({ - tokens: { access_token: jwt(plan, member), account_id: accountId }, + tokens: { access_token: jwt(plan, member, userId, accountId), account_id: accountId }, })); if (observe) reconcileMainCodexAccountRuntimeState(); } @@ -83,6 +86,74 @@ afterEach(() => { }); describe("stored Pool duplicate of native main", () => { + test("official rows join the same upstream user without exposing identity claims", async () => { + writeFileSync(join(process.env.OPENCODEX_HOME!, "admin-api-token"), `ocx_admin_${"x".repeat(43)}\n`); + native("shared-account", "plus", email, true, "same-user"); + saveCodexAccountCredential(poolId, { accessToken: jwt("plus", email, "same-user"), + refreshToken: "fake-refresh", chatgptAccountId: "shared-account", expiresAt: Date.now() + 3600_000 }); + const rows = await runPassiveRead(() => listCodexAuthAccounts(config)); + const main = rows.find(row => row.id === "__main__")!; + const pool = rows.find(row => row.id === poolId)!; + expect(main).toMatchObject({ slotId: "__main__", slotKind: "native-main" }); + expect(pool).toMatchObject({ slotId: poolId, slotKind: "pool" }); + expect(main.upstreamAccountKey).toMatch(/^uai1_[0-9a-f]{64}$/); + expect(pool.upstreamAccountKey).toBe(main.upstreamAccountKey); + expect(JSON.stringify(rows)).not.toContain("same-user"); + expect(JSON.stringify(rows)).not.toContain("shared-account"); + + saveCodexAccountCredential(poolId, { accessToken: jwt("plus", email, "other-user"), + refreshToken: "fake-refresh", chatgptAccountId: "shared-account", expiresAt: Date.now() + 3600_000 }); + const changed = await runPassiveRead(() => listCodexAuthAccounts(config)); + expect(changed.find(row => row.id === poolId)?.upstreamAccountKey).not.toBe(main.upstreamAccountKey); + expect(changed.find(row => row.id === "__main__")?.upstreamAccountKey).toBe(main.upstreamAccountKey); + }); + + test("a missing user claim or local secret preserves separate registered slots", async () => { + native(); + const noSecret = await runPassiveRead(() => listCodexAuthAccounts(config)); + expect(noSecret.map(row => row.slotKind)).toEqual(["native-main", "pool"]); + expect(noSecret.every(row => row.upstreamAccountKey === undefined)).toBe(true); + writeFileSync(join(process.env.OPENCODEX_HOME!, "admin-api-token"), `ocx_admin_${"x".repeat(43)}\n`); + const noClaim = await runPassiveRead(() => listCodexAuthAccounts(config)); + expect(noClaim.every(row => row.upstreamAccountKey === undefined)).toBe(true); + }); + test("conflicting native user claims and pool account claims omit join keys", async () => { + writeFileSync(join(process.env.OPENCODEX_HOME!, "admin-api-token"), `ocx_admin_${"x".repeat(43)}\n`); + writeFileSync(join(process.env.CODEX_HOME!, "auth.json"), JSON.stringify({ tokens: { + access_token: jwt("plus", email, "access-user"), + id_token: jwt("plus", email, "different-user"), + account_id: "shared-account", + } })); + reconcileMainCodexAccountRuntimeState(); + saveCodexAccountCredential(poolId, { accessToken: jwt("plus", email, "access-user", "other-account"), + refreshToken: "fake-refresh", chatgptAccountId: "shared-account", expiresAt: Date.now() + 3600_000 }); + const rows = await runPassiveRead(() => listCodexAuthAccounts(config)); + expect(rows).toHaveLength(2); + expect(rows.every(row => row.upstreamAccountKey === undefined)).toBe(true); + expect(rows.map(row => row.slotId)).toEqual(["__main__", poolId]); + }); + test("conflicting replacement native claims remove a previously observed join key", async () => { + writeFileSync(join(process.env.OPENCODEX_HOME!, "admin-api-token"), `ocx_admin_${"x".repeat(43)}\n`); + native("shared-account", "plus", email, true, "same-user"); + expect((await runPassiveRead(() => listCodexAuthAccounts(config)))[0]?.upstreamAccountKey).toMatch(/^uai1_/); + observeMainCodexCollisionIdentity({ + access_token: jwt("plus", email, "same-user", "other-account"), + account_id: "shared-account", + }); + expect((await runPassiveRead(() => listCodexAuthAccounts(config)))[0]?.upstreamAccountKey).toBeUndefined(); + }); + test("malformed present native ID token suppresses a previously observed join key", async () => { + writeFileSync(join(process.env.OPENCODEX_HOME!, "admin-api-token"), `ocx_admin_${"x".repeat(43)}\n`); + native("shared-account", "plus", email, true, "same-user"); + expect((await runPassiveRead(() => listCodexAuthAccounts(config)))[0]?.upstreamAccountKey).toMatch(/^uai1_/); + observeMainCodexCollisionIdentity({ + access_token: jwt("plus", email, "same-user"), + id_token: "malformed", + account_id: "shared-account", + }); + expect((await runPassiveRead(() => listCodexAuthAccounts(config)))[0]?.upstreamAccountKey).toBeUndefined(); + expect(codexAccountUnusableReason(config, poolId)).toBe("duplicate_main"); + }); test("existing duplicate is ineligible without deleting its credential or config row", () => { native(); expect(codexAccountUnusableReason(config, poolId)).toBe("duplicate_main"); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 84e2aa7d4e7..dc05b2fb629 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -148,6 +148,9 @@ "server-combo-plan-model-refusal.test.ts": "server", "codex-main-token-invalidated.test.ts": "codex-integration", "codex-main-grant-refusal.test.ts": "codex-integration", + "codex-auth-headless-accounts.test.ts": "codex-integration", + "codex-account-slot-identity.test.ts": "codex-integration", + "cli-account-slot-identity.test.ts": "cli", "responses-alternate-main-cancellation.test.ts": "responses", "codex-shim-path-readiness.test.ts": "codex-integration", "codex-shim-fnm.test.ts": "codex-integration", From e8ff1b2061465782a61aafbba4ffa15f66b97a21 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 24 Oct 2026 19:47:27 +0900 Subject: [PATCH 013/101] docs: align localized Mistral and OpenGateway provider guidance (#7646) Carry #7432 and fill all seven provider reference tables, preserving same-key versus replacement-key recovery boundaries. Co-authored-by: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Co-authored-by: MannXo <17333793+MannXo@users.noreply.github.com> --- docs-site/src/content/docs/fr/guides/providers.md | 11 +++++++++-- .../docs/fr/reference/configuration/providers.md | 3 ++- docs-site/src/content/docs/ja/guides/providers.md | 10 +++++++++- .../docs/ja/reference/configuration/providers.md | 3 ++- docs-site/src/content/docs/ko/guides/providers.md | 6 ++++-- .../docs/ko/reference/configuration/providers.md | 3 ++- docs-site/src/content/docs/ru/guides/providers.md | 11 +++++++++-- .../docs/ru/reference/configuration/providers.md | 3 ++- docs-site/src/content/docs/tr/guides/providers.md | 11 +++++++++-- .../docs/tr/reference/configuration/providers.md | 3 ++- docs-site/src/content/docs/zh-cn/guides/providers.md | 10 +++++++++- .../docs/zh-cn/reference/configuration/providers.md | 3 ++- docs-site/src/content/docs/zh-tw/guides/providers.md | 11 +++++++++-- .../docs/zh-tw/reference/configuration/providers.md | 2 ++ 14 files changed, 72 insertions(+), 18 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/providers.md b/docs-site/src/content/docs/fr/guides/providers.md index cdb55807f52..5fc71d2fbb9 100644 --- a/docs-site/src/content/docs/fr/guides/providers.md +++ b/docs-site/src/content/docs/fr/guides/providers.md @@ -67,8 +67,9 @@ La relance d'une requête 429 avec la même clé, configurée par [`retryOn429`](/fr/reference/configuration/), s'applique uniquement aux fournisseurs à clé API (`authMode: "key"`). Les préréglages OAuth, de transfert et locaux sont exclus : leurs identifiants ne doivent jamais être réutilisés sur le même jeton, et les environnements locaux ne possèdent aucune clé distante à -préserver. Cette fonction est facultative : elle est désactivée lorsque l'option est absente ; la présence de -l'objet l'active, sauf si `enabled: false`. +préserver. Cette fonction est facultative, sauf pour les valeurs par défaut documentées propres à certaines destinations. Le Chat Mistral canonique utilise une politique de relance patiente (6 relances, intervalle de repli de 10 secondes), dans la limite de l’allocation d’envois existante pour l’ensemble de la requête. Les réglages explicites de `retryOn429`, y compris `enabled: false`, sont prioritaires. Le préréglage Mistral transmet aussi par défaut les clés de cache de prompt fournies ; `promptCacheKey: false` désactive cette transmission. L’affinité de cache augmente les chances de réutilisation, mais ne garantit pas un succès de cache et ne lève pas les limites de débit de jetons. + +Mistral utilise un seul fournisseur à clé API pour les clés Studio et Vibe. Les types de clés et les allocations des offres sont décrits dans la [documentation des clés API de Mistral](https://docs.mistral.ai/admin/identity-access/api-keys) ; le proxy ne déduit pas le niveau d’abonnement à partir de la clé. Pour tous les modèles, les échéances `Retry-After` explicites renvoyées en amont sont partagées entre les appels qui utilisent les mêmes identifiants et le même modèle. Un 429 sans délai de relance peut déclencher une attente de récupération prudente de 60 secondes lorsque des compteurs par minute valides indiquent une capacité épuisée, ou lorsque le coût mesuré de la requête refusée dépasse les jetons restants tout en tenant dans l’allocation complète annoncée. Il s’agit d’une politique de récupération locale, pas d’une prédiction exacte de la réinitialisation côté serveur. Le trafic sain ne prédit jamais le cache ni le coût de la requête suivante. Après l’attente, une seule sonde de récupération est envoyée ; si elle réussit, le trafic ordinaire reprend. Des indications absentes, mal formées, sans droit d’usage, mensuelles épuisées ou supérieures à l’allocation complète n’inventent ni réinitialisation ni compactage de l’historique. `requestPacing: { "enabled": false }` ou `retryOn429: { "respectRetryAfter": false }` désactive le cadencement partagé. Pour une relance avec la même clé, si le délai de relance de Mistral dépasse `retryOn429.maxIntervalMs`, le proxy renvoie le 429 et l’indication de relance au lieu de relancer trop tôt. Lorsque la sélection des clés en rotation est activée, les requêtes en attente revérifient le refroidissement des clés et acquièrent la capacité de cadencement d’une clé de remplacement, et un rejet de clé avant toute sortie peut être récupéré avec une clé saine dans la limite d’envois existante de la requête. Les fenêtres de contexte et les préférences de compactage par modèle restent indépendantes. ## 1. Connexion ChatGPT (transfert direct) @@ -384,6 +385,12 @@ par Sionic, `deepseek/deepseek-v4.1-flash-ultrafast` et `z-ai/glm-5.3-flash-ultr sont affichés en premier. Créez une clé dans le [tableau de bord OpenGateway](https://opengateway.ai/api-keys), puis lancez `ocx provider add opengateway` ou sélectionnez **OpenGateway** dans le tableau de bord. Les requêtes chat utilisent la clé Bearer configurée ; la liste publique ne valide pas cette clé. +Sur le protocole Responses, OpenGateway refuse les appels d’outils personnalisés natifs rejoués ; le préréglage convertit donc les outils personnalisés Codex, comme `exec` en mode code, en outils de fonction avant l’envoi. +Les tableaux de contenu des résultats d’outils sont envoyés sous forme de texte, et les images sont conservées après le groupe de résultats d’outils associé. Les parties de résultat non prises en charge, y compris les refus et les contenus opaques, sont rejetées explicitement par une erreur d’entrée client avant l’envoi. +Le niveau de détail d’image `original` est converti en `high`, que la passerelle accepte ; les octets de l’image restent inchangés. +La route Responses native vérifie aussi les complétions vides en texte seul avant de les présenter comme un succès. +Un budget de sortie épuisé produit `incomplete` avec `max_output_tokens` ; le proxy n’augmente pas le budget demandé. Une complétion vide qui peut être relancée sans risque et qui dispose encore de budget peut être retentée une seule fois, dans la limite d’envois existante. `emptyCompletionRetry: false` explicite ou `OCX_EMPTY_COMPLETION_RETRY=0` désactive cette nouvelle tentative. Le texte, les refus, l’activité d’outils et les événements de flux inconnus empêchent la relance et conservent le flux d’origine. Cette protection ne modifie ni les autres fournisseurs ni leurs valeurs par défaut de nouvelle tentative. +La relance exige `store: false` explicite, une entrée complète et uniquement des outils exécutés par le client ; un outil hébergé comme `web_search` empêche la relance automatique même si aucun événement d’outil n’a été observé. **OpenCode Zen** (`opencode-zen`) utilise `https://opencode.ai/zen/v1` avec une clé API obtenue sur [opencode.ai/auth](https://opencode.ai/auth). Les modèles gratuits peuvent subir une limite courte d’environ 15–20 requêtes/minute (mesure communautaire ; OpenCode ne publie pas de RPM). Si une réponse 429 de limitation omet `Retry-After`, opencodex ajoute des indications et un `Retry-After` synthétique ; les en-têtes valides en amont restent prioritaires. Les tentatives avec la même clé restent facultatives via [`retryOn429`](/fr/reference/configuration/). 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 f9078613bca..031ce647b2b 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -153,7 +153,8 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix | `terminalContinuationGuard?` | `boolean` | Active, pour un fournisseur `openai-chat`, une relance interne bornée lorsqu’un tour exploitable annonce une action puis s’arrête proprement sans appel d’outil. La valeur par défaut est `false`, et une valeur explicite `false` équivaut à l’absence du champ. Les tentatives de combinaison et les tours de compactage routés sont exclus ; les autres adaptateurs ignorent cette option. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | Réparation SSE en aval désactivée par défaut pour les identifiants d'espace réservé exacts, les identifiants de terminal manquants et (avec `repairInvalidIds`) les identifiants message/reasoning manquant du préfixe canonique `msg_`/`rs_`. Les identifiants d’appel de fonction ne sont jamais réécrits. Le DeepSeek intégré active les deux derniers par défaut. | | `responsesSnapshotRepair?` | `boolean` | Réparation côté client désactivée par défaut pour les instantanés du cycle de vie des réponses clairsemés dans SSE et JSON. Remplit les métadonnées d'état canonique, de sortie et d'outil manquantes tandis que l'inspection brute et la persistance restent inchangées. | -| `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | Fournisseurs à clé API uniquement (`authMode: "key"`). Nouvelle tentative facultative sur la même cible après un 429 : lorsque `retryOn429` est absent, la fonctionnalité est désactivée ; la présence d'un objet l'active, sauf avec `enabled: false`. Après un 429, le proxy attend selon `Retry-After` reçu en amont ou selon l'intervalle fixe, puis relit la requête à l'identique avec la même clé avant tout basculement de clé. Ce comportement couvre la boucle principale de récupération d'un tour textuel, le protocole de transfert Responses, le pont d'images et de vidéos, le service auxiliaire de recherche Web et les continuations du terminal. Seules les réponses HTTP 429 reçues avant le début de la diffusion peuvent être relues ; les transports `runTurn` personnalisés ne font pas partie de la boucle de nouvelle tentative HTTP. `attempts` compte les relectures avec la même clé après le premier 429, soit `attempts` + 1 envois au total, et constitue un budget commun à toute la requête, partagé entre la boucle principale de récupération, la continuation de la garde du terminal et les nouvelles tentatives du pont. L'épuisement de `attempts` arrête uniquement les relectures supplémentaires avec la même clé : le basculement normal de clé ou la gestion de l'erreur finale s'applique ensuite selon les cibles disponibles. Sur le protocole de transfert authentifié par clé, aucun basculement n'est possible ; le 429 final est donc renvoyé sans modification. Codex ne retente jamais lui-même une requête après un 429 : cette option constitue ainsi la seule protection pour les fournisseurs à clé unique. Valeurs par défaut : `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000` (chaque attente est plafonnée à `maxIntervalMs`, lui-même plafonné à 600000), `respectRetryAfter: true`. | +| `promptCacheKey?` | `boolean` | Active pour tout le fournisseur `openai-chat` la transmission de `prompt_cache_key`. L’adaptateur transmet la clé reçue sans en inventer ; la traduction Claude Messages peut toutefois la dériver de `metadata.user_id`, ou d’un groupe modèle/système/outils sans métadonnées. Désactivé par défaut sauf dans un préréglage ; Mistral l’active selon son [contrat de cache](https://docs.mistral.ai/studio/conversations/advanced/prompt-caching). `false` explicite le désactive. N’activer que si l’amont documente ce champ, car une passerelle stricte peut répondre HTTP 400. | +| `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | Réservé aux fournisseurs à clé API (`authMode: "key"`). Facultatif : absent, désactivé, sauf pour OpenCode Go et Command Code sur leurs destinations canoniques et Mistral canonique avec `openai-chat` : 6 relances, intervalle de 10 s, plafond de 60 s. Un objet active la fonction ; `enabled: false` désactive aussi ces valeurs de repli. Après un HTTP 429 avant le flux, attend `Retry-After` ou l’intervalle fixe, puis renvoie la même requête avec la même clé avant le basculement. Couvre la récupération du tour texte, Responses passthrough, les ponts image/vidéo, le service web-search et les continuations terminales ; les transports `runTurn` personnalisés sont exclus. `attempts` compte les relances après le premier 429 (envois totaux = `attempts` + 1), dans un budget partagé à l’échelle de la requête. Son épuisement arrête les relances avec la même clé, puis laisse agir le basculement ou l’erreur finale ; le passthrough à clé n’a pas de basculement. Pour Mistral Chat canonique, un délai explicite supérieur à l’attente autorisée arrête la relance avec la même clé et conserve le `Retry-After` original ; une autre clé du pool peut encore servir. Les autres fournisseurs conservent le délai plafonné. Mistral partage les échéances par identifiant/modèle, sauf avec `requestPacing.enabled: false` ou `retryOn429.respectRetryAfter: false`. Un 429 sans échéance avec une pression validée sur le budget par minute déclenche une attente prudente de 60 s, puis une seule sonde ; son succès rétablit le trafic ordinaire. Le trafic sain ne prédit pas le coût suivant. La politique utilise les allocations annoncées, exclut les allocations mensuelles connues comme épuisées, ne prédit pas une réinitialisation exacte et ne déclenche pas de compactage. Codex ne relance pas lui-même les 429. Valeurs par défaut de l’objet : `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000` (attente plafonnée ; valeur maximale 600000), `respectRetryAfter: true`. | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | Fournisseurs `openai-chat` et `openai-responses` authentifiés par clé uniquement. Les fournisseurs `authMode: "forward"` (le pool de comptes ChatGPT) ne lisent jamais cette option et conservent l'échelle par défaut. Nouvelle tentative facultative pour les états transitoires reçus en amont avant le début de la diffusion (500, 502, 503, 504, 520, 521, 522) : l'absence de l'option la désactive ; la présence d'un objet l'active, sauf avec `enabled: false`. Ce comportement couvre la requête Responses initiale, la continuation de la garde du terminal, le point de terminaison natif `/v1/chat/completions` et les réémissions liées à la récupération après un 429 ou à la récupération de compte. `attempts` représente le nombre TOTAL d'envois en amont autorisés pour une requête, premier envoi compris (de 1 à 10, valeur par défaut : 3). Il constitue un budget commun à la requête, partagé avec la récupération après une réinitialisation de connexion ; ainsi, `3` signifie qu'au plus trois requêtes réelles atteignent le fournisseur. Les attentes utilisent une temporisation exponentielle à base fixe de 400 ms, plafonnée à 5 s, et respectent `Retry-After`. Cette option est distincte de `retryOn429`, qui traite la limitation de débit ; les échecs en cours de diffusion ne sont jamais relus. Chaque cible Combo compte son premier envoi prépayé dans son propre total configuré : `attempts: 1` autorise un envoi, sans rembourser la réservation ni élargir le plafond partagé. Un refus local ou une annulation avant l'envoi libère la réservation inutilisée. | | `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | Fournisseurs `openai-responses` natifs, `authMode: "forward"` compris, ainsi que les requêtes Responses traduites envoyées par la voie générique avant les en-têtes reçus en amont. Remplacement facultatif d'un envoi qui a échoué alors que l'appelant n'avait rien observé : l'absence de l'option la désactive ; la présence d'un objet l'active, sauf avec `enabled: false`. Couvre les deux étapes ambiguës — une connexion rompue avant tout en-tête de réponse, et un corps SSE rompu après l'en-tête alors qu'il ne portait que des événements de contrôle. Une WebSocket canonique ChatGPT en amont qui s'est fermée ou a échoué après l'envoi de sa trame de création, avant tout événement Responses, est couverte de la même façon ; son remplacement est envoyé en HTTP. Seule une requête autonome est remplacée : `store: false`, `input` complet, ni `previous_response_id`, ni `conversation`, ni `stream_id`, et uniquement des outils exécutés par le client. `replacements` est le nombre d'envois de remplacement qu'UNE requête logique peut effectuer, toutes étapes et tous enfants de combo confondus (de 1 à 2, valeur par défaut : 1). Ce n'est ni un nombre de tentatives par étape ni un budget d'envoi : un remplacement doit toujours tenir dans l'allocation d'envois dont l'étape disposait déjà. Une requête qui a déjà émis une sortie ou un appel d'outil n'est jamais remplacée, quelle que soit cette valeur. L'inférence de remplacement peut tout de même être facturée si l'origine avait déjà démarré la première, d'où la désactivation par défaut. La voie générique de traduction prend en charge le remplacement après une rupture avant les en-têtes pour les envois initiaux et reconstruits, avec la même autorisation et le même budget d’envois. Les transports gérés par les adaptateurs et les erreurs des flux traduits après les en-têtes reçus en amont sont exclus. | | `autoToolChoiceOnlyModels?` | `string[]` | Modèles dont `tool_choice` accepte uniquement `auto` ou `none` ; les choix forcés sont dévalorisés. | diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index e3089cf28ab..b2d71808ef0 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -57,7 +57,9 @@ Codex login を Pool モードで使うと、Providers の概要には任意の [`retryOn429`](/ja/reference/configuration/)(同一キーでの 429 リトライ)は API キー プロバイダー (`authMode: "key"`)のみに適用されます。OAuth・forward・ローカル プリセットは除外されます — 同じトークンを再送すべきではなく、ローカルランタイムには保存すべきリモートキーがありません。 -オプトインです: オプションが無ければ無効、オブジェクトがあれば `enabled: false` でない限り有効です。 +ドキュメントに記載された宛先固有のデフォルトを除き、オプトインです。正規の Mistral Chat は、既存のリクエスト全体の送信枠の範囲内で、粘り強いリトライポリシー(再送 6 回、フォールバック間隔 10 秒)を使います。`enabled: false` を含む明示的な `retryOn429` 設定が優先されます。Mistral プリセットは、渡されたプロンプトキャッシュキーもデフォルトで転送します。`promptCacheKey: false` で転送を無効にできます。キャッシュアフィニティは再利用の可能性を高めますが、キャッシュヒットを保証せず、トークンスループットの制限もなくしません。 + +Mistral は Studio キーと Vibe キーの両方に 1 つの API キー プロバイダーを使います。キーの種類とプランの許容量は [Mistral の API キーのドキュメント](https://docs.mistral.ai/admin/identity-access/api-keys)に記載されています。プロキシはキーからサブスクリプションのティアを推測しません。すべてのモデルで、上流が明示した `Retry-After` の期限は、同じ認証情報とモデルでの呼び出し間で共有されます。リトライ時刻のない 429 は、有効な分単位カウンターが容量の枯渇を示す場合、または拒否されたクエリの実測コストが残りトークンを超えつつ公表された全許容量には収まる場合に、控えめな 60 秒の回復待機を開始することがあります。これはローカルの回復ポリシーであり、サーバーのリセット時刻を正確に予測するものではありません。正常なトラフィックでは、次のクエリのキャッシュやコストを予測しません。待機後に回復プローブを 1 回だけ送り、成功すれば通常のトラフィックに戻ります。欠落、不正な形式、利用権ゼロ、月間枠の枯渇、全許容量超過といった根拠からは、リセットを作り出したり履歴をコンパクトしたりしません。`requestPacing: { "enabled": false }` または `retryOn429: { "respectRetryAfter": false }` で共有ペーシングを無効にします。同じキーでの再送について、Mistral のリトライ時刻が `retryOn429.maxIntervalMs` を超える場合、プロキシは早すぎる再送をせず、429 とリトライのヒントを返します。ラウンドロビンのキー選択が有効な場合、待機中のリクエストはキーのクールダウンを再確認して代替キーのペーシング容量を確保し、出力前のキー拒否はリクエストの既存の送信上限内で正常なキーに切り替えて回復できます。コンテキストウィンドウとモデルごとのコンパクション設定は独立したままです。 ## 1. ChatGPT ログイン(forward / パススルー) @@ -280,6 +282,12 @@ Chat Completions モデル(および Responses 専用で Responses にルー [OpenGateway ダッシュボード](https://opengateway.ai/api-keys)でキーを作成し、 `ocx provider add opengateway` を実行するか、ダッシュボードで **OpenGateway** を選択してください。 Chat リクエストは設定済みの Bearer キーを使います。公開一覧はキーの有効性を検証しません。 +Responses では OpenGateway がネイティブのカスタムツール呼び出しの再送を拒否するため、プリセットはコードモードの `exec` など Codex のカスタムツールを送信前に関数ツールへ変換します。 +ツール結果のコンテンツ配列はテキストとして送り、画像は対応するツール結果グループの後に保持します。refusal や不透明なコンテンツなど、サポートされない結果パートは送信前に明示的なクライアント入力エラーとして拒否します。 +画像の詳細度 `original` はゲートウェイが受け付ける `high` に変換し、画像のバイトはそのまま保ちます。 +ネイティブ Responses ルートは、テキストのみの空の完了を成功として返す前に検査します。 +出力予算を使い切った場合は `max_output_tokens` を伴う `incomplete` になり、プロキシは要求された予算を増やしません。予算が残っていて安全に再送できる空の完了は、既存の送信上限の範囲内で 1 回だけ再試行することがあります。明示的な `emptyCompletionRetry: false` または `OCX_EMPTY_COMPLETION_RETRY=0` でこの再試行を無効にします。テキスト、refusal、ツールの動作、未知のストリームイベントがあると再送せず、元のストリームを保持します。この保護は他のプロバイダーやそのリトライのデフォルトを変更しません。 +再送には明示的な `store: false`、完全な入力、クライアント実行のツールのみが必要です。`web_search` などのホスト型ツールがあると、ツールイベントが観測されていなくても自動再送しません。 **OpenCode Zen**(`opencode-zen`)は [opencode.ai/auth](https://opencode.ai/auth) の API キーで `https://opencode.ai/zen/v1` を使用します。無料モデルは毎分約 15–20 リクエストの短時間レート制限に当たることがあります(コミュニティ計測。OpenCode は RPM を公表していません)。レート制限 429 に `Retry-After` がない場合、opencodex は案内と合成 `Retry-After` を追加し、有効な上流ヘッダーを優先します。同一キーの再試行は [`retryOn429`](/ja/reference/configuration/) で有効にします。 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 63f3894e54d..b9b4816488e 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -145,7 +145,8 @@ account を削除しても mapping は保持され、同じ id を再追加す | `parallelToolCalls?` | `boolean` |並列ツール呼び出しを切り替えます。 OpenAI Chat はデフォルトでオンになっています。非チャット アダプターは明示的な `true` でのみアドバタイズします。 | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` |正確なプレースホルダー ID、欠落している端末 ID、および(`repairInvalidIds` で)正規の `msg_`/`rs_` 接頭辞を欠く message/reasoning ID に対するダウンストリーム SSE 修復はデフォルトで無効になっています。関数呼び出し ID は決して書き換えられません。組み込み DeepSeek は最後の 2 つをデフォルトで有効にします。 | | `responsesSnapshotRepair?` | `boolean` | デフォルトで無効のクライアント向け修復です。SSE と JSON の Responses ライフサイクルで欠落した status、output、ツールメタデータを補完し、raw 検査と永続化は変更しません。 | -| `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | API-key プロバイダーのみ(`authMode: "key"`)。オプトインの同一ターゲット 429 リトライ: `retryOn429` が無ければ無効で、オブジェクトがあれば `enabled: false` でない限り有効になります。429 時に待機(上流の `Retry-After` または固定間隔)してから、キー フェイルオーバーの前に同一キーで同一リクエストを再送します — メインのテキストターン回復ループ、Responses passthrough、画像/動画ブリッジ、web-search サイドカー、ターミナル継続要求をすべてカバーします。再送の対象はプリストリームの HTTP 429 応答のみで、カスタム `runTurn` トランスポートは HTTP リトライループの対象外です。`attempts` は最初の 429 以降の同一キー再送回数(合計送信数 = `attempts` + 1)で、メインの回復ループ・ターミナルガード継続・ブリッジ再試行で共有されるリクエスト単位の予算です。`attempts` を使い切っても同一キーでの再送が止まるだけで、通常のキー フェイルオーバーまたは最終エラー処理が利用可能なターゲットに応じて続きます — キー認証の passthrough ワイヤにはフェイルオーバーがないため、使い切った 429 はそのまま返ります。Codex 自体は 429 をリトライしないため、単一キーのプロバイダーでは唯一の防御です。デフォルト: `enabled: true`、`attempts: 3`、`intervalMs: 5000`、`maxIntervalMs: 60000`(1回の待機は `maxIntervalMs` で上限、その上限は 600000)、`respectRetryAfter: true`。 | +| `promptCacheKey?` | `boolean` | `openai-chat` のプロバイダー全体で `prompt_cache_key` の転送を有効にします。アダプターは受け取ったキーを転送し生成しませんが、Claude Messages 変換では `metadata.user_id`、またはメタデータがなければモデル・システム・ツールの組み合わせから導出します。プリセットが指定しない限り既定は無効です。Mistral は[プロンプトキャッシュ契約](https://docs.mistral.ai/studio/conversations/advanced/prompt-caching)に従い有効で、明示的な `false` で無効になります。上流が対応を明記する場合のみ有効にしてください。厳格なゲートウェイは未知のフィールドを HTTP 400 で拒否する場合があります。 | +| `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | API キーのプロバイダーのみ(`authMode: "key"`)。未設定なら無効ですが、正規の OpenCode Go と Command Code、および正規の Mistral の `openai-chat` は例外で、再送 6 回、間隔 10 秒、上限 60 秒のポリシーを使います。オブジェクトを指定すると有効になり、`enabled: false` はこの既定ポリシーも無効にします。ストリーム開始前の HTTP 429 で `Retry-After` または固定間隔を待ち、キーの切り替え前に同じキーで同じリクエストを再送します。テキストターンの回復、Responses passthrough、画像・動画ブリッジ、web-search サイドカー、ターミナル継続が対象で、独自の `runTurn` は対象外です。`attempts` は最初の 429 後の同一キー再送回数(総送信数 = `attempts` + 1)で、リクエスト全体で共有します。使い切ると同一キーの再送だけが止まり、通常のキー切り替えか最終エラー処理に進みます。キー認証の passthrough では切り替えがなく、そのまま 429 を返します。正規の Mistral Chat で明示的な上流の待機時間が許容上限を超えると、同一キー再送を止めて元の `Retry-After` を返しますが、別のプールキーが処理できる場合があります。他のプロバイダーでは待機時間を上限で切り詰めます。Mistral は認証情報とモデルごとに期限を共有します。`requestPacing.enabled: false` または `retryOn429.respectRetryAfter: false` で無効になります。再試行時刻のない 429 で分単位の予算不足が検証されると、控えめな 60 秒の回復待機後、プローブを 1 回実行し、成功すれば通常のトラフィックを再開します。正常なトラフィックから次のトークン消費は予測しません。公表された許容量を使い、既知の月間枠の枯渇は除外し、正確なリセット時刻を保証せず、履歴の圧縮も起動しません。Codex 自体は 429 を再試行しません。オブジェクトの既定値:`enabled: true`、`attempts: 3`、`intervalMs: 5000`、`maxIntervalMs: 60000`(待機の上限、設定の最大値は 600000)、`respectRetryAfter: true`。 | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | キー認証の `openai-chat` および `openai-responses` プロバイダーのみ。`authMode: "forward"` のプロバイダー(ChatGPT アカウントプール)はこのオプションを読まず、既定の再試行段数を維持します。ストリーム開始前に上流から返される一時的なステータス(500、502、503、504、520、521、522)に対するオプトインの再試行です。設定がなければ無効で、オブジェクトを指定すると `enabled: false` でない限り有効になります。最初の Responses リクエスト、ターミナルガード継続、ネイティブの `/v1/chat/completions`、および 429/アカウント回復時の再取得が対象です。`attempts` は最初の送信を含め、1 回のリクエストで許可される上流への送信総数です(1~10、デフォルトは 3)。接続リセット回復と共有するリクエスト単位の単一予算であるため、`3` を指定した場合、プロバイダーに到達する実リクエストは最大 3 回です。待機には 400 ms を基準とする固定式の指数バックオフを使用し、上限は 5 秒で、`Retry-After` に従います。レート制限を扱う `retryOn429` とは別の機能であり、ストリーム開始後の失敗は再送されません。 各 Combo ターゲットでは、事前予約した初回送信もそのターゲットの設定総数に含まれます。`attempts: 1` は 1 回だけ送信し、予約を返却して余裕を作ったり、共有上限を広げたりしません。送信前のローカル拒否やキャンセルでは未使用の予約を解放します。 | | `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | ネイティブ `openai-responses` プロバイダー(`authMode: "forward"` を含む)と、上流ヘッダー受信前の汎用 Responses 変換経路に適用されます。呼び出し側が何も観測しないまま失敗した送信を、オプトインで置き換えます。設定がなければ無効で、オブジェクトを指定すると `enabled: false` でない限り有効になります。レスポンスヘッダーが届く前に接続が切れた場合と、ヘッダー後に SSE 本文が制御イベントだけを運んだまま切れた場合の両方が対象です。canonical ChatGPT upstream WebSocket で create フレームの送信後、Responses イベントが届く前にソケットが閉じたかエラーになった場合も同じく対象で、その置き換えは HTTP で送信します。置き換えるのは自己完結したリクエストだけで、`store: false`、完全な `input`、`previous_response_id` / `conversation` / `stream_id` がないこと、クライアントが実行するツールのみ、が条件です。`replacements` は、すべてのレッグとすべてのコンボ子リクエストを合わせて 1 つの論理リクエストが行える置き換え送信の回数です(1..2、デフォルトは 1)。レッグ単位の再試行回数でも送信予算でもないため、置き換え送信もそのレッグがすでに持つ送信許容量に収まる必要があります。すでに出力やツール呼び出しを送ったリクエストは、この値に関わらず置き換えません。元の送信がすでに開始されていた場合は置き換えた推論も課金される可能性があるため、既定では無効です。 汎用変換経路では、初回送信と再構築後の送信のどちらも、同じ許可と送信予算でヘッダー受信前の接続リセットを置き換えます。アダプター独自のトランスポートと、上流ヘッダー受信後の変換ストリーム障害は対象外です。 | | `autoToolChoiceOnlyModels?` | `string[]` | `tool_choice` が `auto` または `none` のみを受け入れるモデル。強制的な選択は格下げされます。 | diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 54507281e9b..4f1e904d4b0 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -55,8 +55,9 @@ shipped v1 config는 marker 2의 단일 옵션 행으로 자동 이관됩니다. [`retryOn429`](/ko/reference/configuration/)(동일 키 429 재시도)는 API 키 프로바이더 (`authMode: "key"`)에만 적용됩니다. OAuth·forward·로컬 프리셋은 제외됩니다 — 같은 토큰을 -재전송해서는 안 되며, 로컬 런타임에는 보존할 원격 키가 없습니다. 옵트인입니다: 옵션이 없으면 -꺼져 있고, 객체가 있으면 `enabled: false`가 아닌 한 활성화됩니다. +재전송해서는 안 되며, 로컬 런타임에는 보존할 원격 키가 없습니다. 문서화된 대상별 기본값을 제외하면 옵트인입니다. 정식 Mistral Chat은 기존 요청 단위 전송 한도 안에서 끈질긴 재시도 정책(재전송 6회, 대체 간격 10초)을 사용합니다. `enabled: false`를 포함한 명시적인 `retryOn429` 설정이 우선합니다. Mistral 프리셋은 전달받은 프롬프트 캐시 키도 기본으로 전달하며, `promptCacheKey: false`로 전달을 끌 수 있습니다. 캐시 친화성은 재사용 가능성을 높이지만 캐시 적중을 보장하거나 토큰 처리량 제한을 없애지는 않습니다. + +Mistral은 Studio 키와 Vibe 키 모두에 하나의 API 키 프로바이더를 사용합니다. 키 종류와 요금제 허용량은 [Mistral API 키 문서](https://docs.mistral.ai/admin/identity-access/api-keys)에 설명되어 있으며, 프록시는 키에서 구독 등급을 추론하지 않습니다. 모든 모델에서 업스트림이 명시한 `Retry-After` 기한은 같은 자격 증명과 모델의 호출 사이에서 공유됩니다. 재시도 시각이 없는 429는 유효한 분 단위 카운터가 용량 소진을 보여 주거나, 거절된 쿼리의 측정 비용이 남은 토큰을 넘지만 공시된 전체 허용량 안에 들어올 때 보수적인 60초 복구 대기를 시작할 수 있습니다. 이는 로컬 복구 정책이며 서버 재설정 시각을 정확히 예측하는 것이 아닙니다. 정상 트래픽에서는 다음 쿼리의 캐시나 비용을 예측하지 않습니다. 대기 후 복구 프로브를 한 번 보내고, 성공하면 일반 트래픽으로 돌아갑니다. 근거가 없거나, 형식이 잘못됐거나, 이용 권한이 0이거나, 월간 한도가 소진됐거나, 전체 허용량을 넘는 경우에는 재설정을 지어내거나 기록을 압축하지 않습니다. `requestPacing: { "enabled": false }` 또는 `retryOn429: { "respectRetryAfter": false }`로 공유 페이싱을 끕니다. 같은 키로 재전송할 때 Mistral의 재시도 시각이 `retryOn429.maxIntervalMs`를 넘으면 프록시는 일찍 재전송하지 않고 429와 재시도 힌트를 반환합니다. 라운드 로빈 키 선택이 켜져 있으면 대기 중인 요청은 키 쿨다운을 다시 확인하고 대체 키의 페이싱 용량을 확보하며, 출력 전의 키 거절은 요청의 기존 전송 한도 안에서 정상 키로 복구될 수 있습니다. 컨텍스트 창과 모델별 압축 설정은 서로 독립적으로 유지됩니다. ## 1. ChatGPT 로그인 (forward / 패스스루) @@ -285,6 +286,7 @@ Chat Completions 모델(그리고 Responses 전용이라 Responses로 보내는 [OpenGateway 대시보드](https://opengateway.ai/api-keys)에서 키를 발급한 뒤 `ocx provider add opengateway`를 실행하거나 대시보드에서 **OpenGateway**를 선택하세요. Chat 요청은 설정한 Bearer 키를 사용하며 공개 모델 목록은 키의 유효성을 검증하지 않습니다. +OpenGateway는 Responses에서 네이티브 커스텀 도구 호출의 재전송을 거절하므로 프리셋은 코드 모드의 `exec` 등 Codex 커스텀 도구를 전송 전에 함수 도구로 변환합니다. Responses 도구 결과 배열은 텍스트로 변환하고 이미지는 짝이 맞는 도구 결과 묶음 뒤의 메시지에 보존합니다. refusal이나 불투명한 내용 등 지원하지 않는 결과 part는 전송 전에 명시적인 클라이언트 입력 오류로 거절합니다. 이미지의 `original` 상세도는 게이트웨이가 지원하는 `high`로 바꾸며 이미지 바이트는 그대로 유지합니다. 네이티브 Responses 경로는 텍스트가 없는 완료 응답도 검사합니다. 출력 예산이 소진됐으면 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 54696304ef5..a976c12e1af 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -145,7 +145,8 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | `parallelToolCalls?` | `boolean` | 병렬 도구 호출을 켜거나 끕니다. OpenAI Chat은 기본으로 켜져 있고, 비-chat 어댑터는 명시적으로 `true`일 때만 이를 노출합니다. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | 기본값이 꺼진 downstream SSE 복구입니다. 정확한 자리표시자 id, 누락된 종료 id, 그리고(`repairInvalidIds`) 정규 `msg_`/`rs_` 접두사가 없는 message/reasoning id를 복구합니다. function-call id는 다시 쓰지 않습니다. 내장 DeepSeek은 마지막 두 가지를 기본으로 켭니다. | | `responsesSnapshotRepair?` | `boolean` | 기본값이 꺼진 클라이언트용 복구입니다. SSE와 JSON의 Responses 수명 주기에서 누락된 status, output, 도구 메타데이터를 채우며 raw 검사와 영속화는 변경하지 않습니다. | -| `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | API-key 프로바이더 전용(`authMode: "key"`). 동일 대상 429 재시도: `retryOn429`가 없으면 기능이 꺼져 있고, 객체가 있으면 `enabled: false`가 아닌 한 활성화됩니다. 429 시 대기(업스트림 `Retry-After` 또는 고정 간격) 후 키 장애 조치 전에 동일 키로 동일 요청을 재전송합니다 — 일반 텍스트 턴 복구 루프, Responses passthrough, 이미지/비디오 브리지, web-search 사이드카, 터미널 연속 요청을 모두 포함합니다. 재전송 대상은 프리스트림 HTTP 429 응답뿐이며, 커스텀 `runTurn` 전송은 HTTP 재시도 루프에서 제외됩니다. `attempts`는 첫 429 이후의 동일 키 재전송 횟수(총 전송 = `attempts` + 1)이며, 메인 복구 루프·터미널 가드 연속 요청·브리지 재시도가 공유하는 요청 단위 예산입니다. `attempts`를 모두 소진해도 동일 키 재전송만 중단되며, 이후에는 일반 키 장애 조치 또는 최종 오류 처리가 사용 가능한 대상에 따라 진행됩니다 — 키 인증 passthrough 와이어에는 장애 조치가 없으므로 소진된 429가 그대로 반환됩니다. Codex 자체는 429를 재시도하지 않으므로 단일 키 프로바이더의 유일한 방어선입니다. 기본값: `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000`(단일 대기는 `maxIntervalMs`로 상한, 그 자체는 600000으로 상한), `respectRetryAfter: true`. | +| `promptCacheKey?` | `boolean` | `openai-chat` 프로바이더 전체에서 `prompt_cache_key` 전달을 켭니다. 어댑터는 받은 키만 전달하지만 Claude Messages 변환은 `metadata.user_id` 또는 메타데이터가 없을 때 모델/시스템/도구 조합에서 키를 유도할 수 있습니다. 프리셋이 선언하지 않으면 기본적으로 꺼집니다. Mistral은 [프롬프트 캐시 계약](https://docs.mistral.ai/studio/conversations/advanced/prompt-caching)에 따라 켜며 명시적 `false`로 끕니다. 엄격한 게이트웨이는 알 수 없는 필드를 HTTP 400으로 거절할 수 있으므로 업스트림이 지원을 명시할 때만 켜세요. | +| `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | API 키 프로바이더 전용(`authMode: "key"`). 미설정 시 꺼지지만 정식 엔드포인트의 OpenCode Go와 Command Code, 정식 Mistral `openai-chat`은 예외로 재전송 6회, 간격 10초, 상한 60초 정책을 사용합니다. 객체를 지정하면 켜지며 `enabled: false`는 이 기본 정책도 끕니다. 스트림 시작 전 HTTP 429에서 `Retry-After` 또는 고정 간격을 기다린 후 키 장애 조치 전에 동일 키로 동일 요청을 재전송합니다. 텍스트 턴 복구, Responses passthrough, 이미지/비디오 브리지, web-search 사이드카, 터미널 연속 요청을 포함하며 커스텀 `runTurn`은 제외됩니다. `attempts`는 첫 429 이후 동일 키 재전송 횟수(총 전송 = `attempts` + 1)로 요청 전체에서 공유됩니다. 소진 시 동일 키 재전송만 중단되고 일반 키 장애 조치나 최종 오류 처리가 이어집니다. 키 인증 passthrough에는 장애 조치가 없어 원래 429를 반환합니다. 정식 Mistral Chat의 명시적 대기 시간이 허용 상한을 넘으면 동일 키 재전송을 중단하고 원래 `Retry-After`를 반환하지만 풀의 다른 키가 처리할 수 있습니다. 다른 프로바이더는 대기 시간을 상한으로 제한합니다. Mistral은 자격 증명/모델별 재시도 기한을 공유하며 `requestPacing.enabled: false` 또는 `retryOn429.respectRetryAfter: false`로 끕니다. 재시도 시각이 없는 429에서 분당 예산 부족이 검증되면 보수적인 60초 복구 대기 후 한 번 탐색하고, 성공하면 일반 트래픽으로 돌아갑니다. 정상 트래픽에서는 다음 토큰 비용을 예측하지 않습니다. 공시된 허용량을 사용하고 소진된 월간 허용량은 제외하며 정확한 초기화 시각을 보장하거나 기록 압축을 유발하지 않습니다. Codex 자체는 429를 재시도하지 않습니다. 객체 기본값: `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000`(대기 상한, 설정 최대 600000), `respectRetryAfter: true`. | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | 키 인증 `openai-chat` 및 `openai-responses` 프로바이더 전용입니다. `authMode: "forward"` 프로바이더(ChatGPT 계정 풀)는 이 옵션을 읽지 않고 기본 재시도 단계를 유지합니다. 스트림 시작 전의 일시적인 업스트림 상태(500, 502, 503, 504, 520, 521, 522)를 선택적으로 재시도합니다. 이 옵션이 없으면 꺼져 있고, 객체가 있으면 `enabled: false`가 아닌 한 활성화됩니다. 최초 Responses 요청, 터미널 가드 연속 요청, 네이티브 `/v1/chat/completions`, 429/계정 복구 재조회를 포함합니다. `attempts`는 최초 전송을 포함하여 요청 하나에 허용되는 업스트림 전송의 총횟수(1..10, 기본값 3)입니다. 연결 재설정 복구와 요청 단위 예산 하나를 공유하므로 `3`이면 실제로 프로바이더에 도달하는 요청은 최대 세 번입니다. 대기에는 400ms로 고정된 지수 백오프를 사용하고 상한은 5초이며 `Retry-After`를 따릅니다. 속도 제한을 처리하는 `retryOn429`와는 별개이며, 스트림 도중의 실패는 절대 재전송하지 않습니다. 각 Combo 대상은 선예약한 첫 전송을 해당 대상의 설정 총횟수에 포함합니다. `attempts: 1`은 한 번만 전송하며, 예약을 환불해 여유를 만들거나 공유 상한을 늘리지 않습니다. 전송 전 로컬 거부나 취소는 미사용 예약을 해제합니다. | | `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | 네이티브 `openai-responses` 프로바이더(`authMode: "forward"` 포함)와 업스트림 헤더 수신 전의 일반 Responses 변환 전송 경로에 적용됩니다. 호출자가 아무것도 관측하지 못한 채 실패한 전송을 선택적으로 대체합니다. 이 옵션이 없으면 꺼져 있고, 객체가 있으면 `enabled: false`가 아닌 한 활성화됩니다. 응답 헤더가 오기 전에 연결이 끊어진 경우와, 헤더 이후 SSE 본문이 제어 이벤트만 실은 채 끊어진 경우를 모두 다룹니다. canonical ChatGPT 업스트림 WebSocket에서 create 프레임을 보낸 뒤 Responses 이벤트가 오기 전에 소켓이 닫히거나 오류가 난 경우도 같은 방식으로 다루며, 이때 대체 전송은 HTTP로 보냅니다. 자체 완결된 요청만 대체합니다. `store: false`, 완전한 `input`, `previous_response_id`·`conversation`·`stream_id` 없음, 클라이언트가 실행하는 도구만 해당합니다. `replacements`는 모든 구간과 모든 콤보 자식을 합쳐 논리 요청 하나가 만들 수 있는 대체 전송 횟수입니다(1..2, 기본값 1). 구간별 재시도 횟수도 전송 예산도 아니므로, 대체 전송도 해당 구간이 이미 가진 전송 허용량 안에 들어가야 합니다. 이미 출력이나 도구 호출을 내보낸 요청은 이 값과 무관하게 대체하지 않습니다. 원본 전송이 이미 시작됐다면 대체한 추론도 과금될 수 있어서 기본값은 꺼짐입니다. 일반 변환 전송 경로에서는 최초 전송과 재구축한 전송 모두 같은 대체 권한과 전송 예산으로 헤더 수신 전 연결 리셋을 대체합니다. 어댑터가 자체 관리하는 전송 경로와 업스트림 헤더 수신 후 변환 스트림 장애는 제외됩니다. | | `autoToolChoiceOnlyModels?` | `string[]` | `tool_choice`가 `auto` 또는 `none`만 받는 모델입니다. 강제 선택은 낮은 수준으로 바뀝니다. | diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index 18f79e64f3f..525053d9838 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -65,8 +65,9 @@ description: Все способы, которыми opencodex аутентиф Повтор при 429 на том же ключе ([`retryOn429`](/ru/reference/configuration/)) применим только к провайдерам с API-ключом (`authMode: "key"`). Пресеты OAuth, forward и local исключены — их учётные данные нельзя повторно отправлять по тому же токену, а у локальных сред выполнения нет -удалённого ключа. Это opt-in: при отсутствии опции функция выключена; наличие объекта включает -её, если только `enabled: false`. +удалённого ключа. Это opt-in, за исключением задокументированных значений по умолчанию для отдельных назначений. Канонический Mistral Chat использует терпеливую политику повторов (6 повторов, резервный интервал 10 секунд) в пределах существующего лимита отправок на весь запрос. Явные настройки `retryOn429`, включая `enabled: false`, имеют приоритет. Пресет Mistral также по умолчанию пересылает переданные ключи кэша промптов; `promptCacheKey: false` отключает пересылку. Привязка к кэшу повышает шанс повторного использования, но не гарантирует попадание в кэш и не снимает лимиты пропускной способности по токенам. + +Mistral использует одного провайдера с API-ключом и для ключей Studio, и для ключей Vibe. Типы ключей и лимиты тарифов описаны в [документации Mistral по API-ключам](https://docs.mistral.ai/admin/identity-access/api-keys); прокси не определяет уровень подписки по ключу. Для всех моделей явные сроки `Retry-After` от апстрима общие для вызовов с одними и теми же учётными данными и моделью. 429 без времени повтора может запустить консервативное 60-секундное ожидание восстановления, если корректные поминутные счётчики показывают исчерпанную ёмкость или измеренная стоимость отклонённого запроса превышает оставшиеся токены, но укладывается в полный заявленный лимит. Это локальная политика восстановления, а не точный прогноз сброса на сервере. При нормальном трафике кэш и стоимость следующего запроса никогда не прогнозируются. После ожидания выполняется одна пробная попытка восстановления; при успехе обычный трафик возобновляется. Отсутствующие, некорректные, с нулевым правом использования, с исчерпанным месячным лимитом или превышающие полный лимит данные не приводят к выдуманному сбросу или сжатию истории. `requestPacing: { "enabled": false }` или `retryOn429: { "respectRetryAfter": false }` отключает общий темп запросов. Для повтора с тем же ключом: если время повтора от Mistral превышает `retryOn429.maxIntervalMs`, прокси возвращает 429 и подсказку о повторе, а не повторяет запрос раньше времени. Если включён выбор ключей по кругу (round-robin), ожидающие запросы перепроверяют периоды охлаждения ключей и получают ёмкость темпа для запасного ключа, а отказ ключа до начала вывода может быть восстановлен на исправном ключе в пределах существующего лимита отправок запроса. Окна контекста и настройки компактизации для отдельных моделей остаются независимыми. ## 1. Вход через ChatGPT (forward / passthrough) @@ -295,6 +296,12 @@ opencodex поставляется с 105 встроенными пресета Создайте ключ в [панели OpenGateway](https://opengateway.ai/api-keys), затем выполните `ocx provider add opengateway` или выберите **OpenGateway** в панели. Chat-запросы используют настроенный Bearer-ключ; публичный список не подтверждает действительность ключа. +В Responses OpenGateway отклоняет повторные нативные вызовы пользовательских инструментов, поэтому пресет перед отправкой преобразует инструменты Codex, например `exec` в режиме кода, в функциональные инструменты. +Массивы содержимого результатов инструментов отправляются как текст, а изображения сохраняются после соответствующей группы результатов инструментов. Неподдерживаемые части результатов, включая отказы и непрозрачное содержимое, явно отклоняются с ошибкой клиентского ввода до отправки. +Детализация изображения `original` преобразуется в `high`, которую принимает шлюз; байты изображения не меняются. +Нативный маршрут Responses также проверяет пустые текстовые завершения, прежде чем выдать их как успешные. +Исчерпанный бюджет вывода даёт `incomplete` с `max_output_tokens`; прокси не увеличивает запрошенный бюджет. Пустое завершение, которое безопасно повторить и у которого остался бюджет, может быть повторено один раз в пределах существующего лимита отправок. Явный `emptyCompletionRetry: false` или `OCX_EMPTY_COMPLETION_RETRY=0` отключает этот повтор. Текст, отказы, активность инструментов и неизвестные события потока исключают повтор, и исходный поток сохраняется. Эта защита не меняет других провайдеров и их значения повторов по умолчанию. +Для повтора нужны явный `store: false`, полный ввод и только инструменты, выполняемые клиентом; размещённый инструмент, например `web_search`, исключает автоматический повтор, даже если событий инструмента не было. **OpenCode Zen** (`opencode-zen`) использует `https://opencode.ai/zen/v1` с API-ключом из [opencode.ai/auth](https://opencode.ai/auth). Бесплатные модели могут упираться в короткий лимит около 15–20 запросов в минуту (оценка сообщества; OpenCode не публикует RPM). Если ответ 429 о лимите частоты не содержит `Retry-After`, opencodex добавляет пояснение и синтетический `Retry-After`; корректные заголовки сервера имеют приоритет. Повторы с тем же ключом включаются через [`retryOn429`](/ru/reference/configuration/). 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 6b73e2e2f82..98f181a7bb6 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -158,7 +158,8 @@ cross-route credential fallback не существует. Строки API GPT- | `parallelToolCalls?` | `boolean` | Переключатель parallel tool call'ов. Для OpenAI Chat по умолчанию включено; не-chat adapter'ы рекламируют это только при явном `true`. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | По умолчанию выключенная downstream SSE-repair для exact placeholder-id, отсутствующих terminal-id и (с `repairInvalidIds`) message/reasoning id без канонического префикса `msg_`/`rs_`. Function-call id никогда не переписываются. Встроенный DeepSeek включает последние два по умолчанию. | | `responsesSnapshotRepair?` | `boolean` | По умолчанию выключенная клиентская repair для неполных lifecycle snapshot'ов Responses в SSE и JSON. Добавляет отсутствующие status, output и tool metadata, не меняя raw inspection и persistence. | -| `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | Только для провайдеров с API-ключом (`authMode: "key"`). Опциональный повтор при 429 на том же таргете: если `retryOn429` отсутствует, функция выключена; наличие объекта включает её, если только `enabled: false`. При 429: ожидание (`Retry-After` апстрима или фиксированный интервал) и повтор идентичного запроса на том же ключе до любого фейловера ключей — покрывает основной цикл восстановления текстовых ходов, passthrough-канал Responses, мост изображений/видео, sidecar web-search и терминальные продолжения. Повтор допустим только для HTTP 429, полученных до начала потока; пользовательские транспорты `runTurn` не входят в цикл HTTP-повторов. `attempts` — это число повторов на том же ключе после первого 429 (всего отправок = `attempts` + 1) и единый бюджет на запрос, общий для основного цикла восстановления, терминального продолжения и повторов моста. Исчерпание `attempts` лишь останавливает дальнейшие повторы на том же ключе; далее применяется обычный фейловер ключей или финальная обработка ошибки в зависимости от доступных таргетов — на passthrough-канале с ключевой аутентификацией фейловера нет, поэтому исчерпанный 429 возвращается как есть. Codex сам никогда не повторяет 429, поэтому это единственная защита для провайдеров с одним ключом. По умолчанию: `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000` (любое ожидание ограничено `maxIntervalMs`, который сам ограничен 600000), `respectRetryAfter: true`. | +| `promptCacheKey?` | `boolean` | Включает передачу `prompt_cache_key` для всего провайдера `openai-chat`. Адаптер передаёт полученный ключ, не создавая свой, но перевод Claude Messages может вывести его из `metadata.user_id` либо из сочетания модели/системы/инструментов без метаданных. По умолчанию выключено, если пресет не указывает иное; Mistral включает по своему [контракту кэширования](https://docs.mistral.ai/studio/conversations/advanced/prompt-caching), явное `false` отключает. Включайте только при документированной поддержке апстримом: строгий шлюз может отклонить неизвестное поле с HTTP 400. | +| `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | Только API-ключи (`authMode: "key"`). Без настройки выключено, кроме канонических OpenCode Go и Command Code и канонического Mistral с `openai-chat`: 6 повторов, интервал 10 с, предел 60 с. Объект включает повторы; `enabled: false` отключает и эти значения по умолчанию. При HTTP 429 до начала потока прокси ждёт `Retry-After` или фиксированный интервал и повторяет тот же запрос с тем же ключом до переключения ключей. Охватывает восстановление текстового хода, Responses passthrough, мосты изображений/видео, web-search и терминальные продолжения; пользовательский `runTurn` исключён. `attempts` — число повторов после первого 429 (всего отправок = `attempts` + 1), общий бюджет всего запроса. Исчерпание прекращает только повторы с тем же ключом, затем применяется обычное переключение или окончательная ошибка; passthrough с ключом не переключается и возвращает 429 как есть. Для канонического Mistral Chat явная задержка сверх разрешённого ожидания прекращает повтор с тем же ключом и сохраняет исходный `Retry-After`; другой ключ пула ещё может обслужить запрос. У остальных провайдеров задержка ограничивается пределом. Mistral разделяет сроки между вызовами с одинаковыми учётными данными и моделью, кроме `requestPacing.enabled: false` или `retryOn429.respectRetryAfter: false`. 429 без времени повтора при подтверждённом дефиците минутного бюджета запускает консервативное ожидание 60 с и одну пробную попытку; успех возвращает обычный трафик. Здоровый трафик не предсказывает следующую стоимость токенов. Политика использует заявленные лимиты, исключает известное исчерпание месячных лимитов, не обещает точное время сброса и не запускает сжатие истории. Сам Codex не повторяет 429. Значения объекта по умолчанию: `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000` (предел ожидания, максимум настройки 600000), `respectRetryAfter: true`. | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | Только для провайдеров `openai-chat` и `openai-responses` с аутентификацией по ключу. Провайдеры с `authMode: "forward"` (пул аккаунтов ChatGPT) никогда не читают эту настройку и сохраняют число повторов по умолчанию. Опциональный повтор при временных статусах апстрима до начала потока (500, 502, 503, 504, 520, 521, 522): если параметр отсутствует, функция выключена; наличие объекта включает её, если только `enabled: false`. Покрывает исходный запрос `Responses`, продолжение терминального предохранителя, нативный `/v1/chat/completions`, а также повторные запросы при восстановлении после 429 или ошибки учётной записи. `attempts` — ОБЩЕЕ число разрешённых отправок в апстрим для одного запроса, включая первую (1..10, по умолчанию 3). Это единый бюджет на запрос, общий с восстановлением после сброса соединения, поэтому `3` означает, что до провайдера дойдут не более трёх реальных запросов. Ожидание использует экспоненциальную задержку с фиксированной начальной величиной 400 мс, ограниченную 5 с, и учитывает `Retry-After`. Параметр не связан с `retryOn429`, который обрабатывает ограничение частоты запросов; сбои после начала потока никогда не воспроизводятся. Для каждой цели Combo первая заранее зарезервированная отправка входит в настроенный итог этой цели: `attempts: 1` допускает одну отправку, без возврата резерва ради дополнительного лимита и без расширения общего потолка. Локальный отказ или отмена до отправки освобождает неиспользованный резерв. | | `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | Для нативных провайдеров `openai-responses`, включая `authMode: "forward"`, а также для общего пути отправки переведённых запросов Responses до получения заголовков от upstream. Необязательная замена отправки, которая завершилась неудачей, когда вызывающая сторона ещё ничего не наблюдала: если параметр отсутствует, функция выключена; наличие объекта включает её, если только `enabled: false`. Покрывает обе неоднозначные стадии — обрыв соединения до любого заголовка ответа и обрыв тела SSE после заголовка, когда оно несло только управляющие события. Так же покрывается upstream WebSocket canonical ChatGPT, который закрылся или завершился ошибкой после отправки кадра create и до любого события Responses; замена в этом случае отправляется по HTTP. Заменяется только самодостаточный запрос: `store: false`, полный `input`, отсутствие `previous_response_id`, `conversation` и `stream_id`, и только инструменты, исполняемые клиентом. `replacements` — число замещающих отправок, которые ОДИН логический запрос может сделать по всем участкам и всем дочерним запросам комбо (1..2, по умолчанию 1). Это не число повторов на участок и не бюджет отправок, поэтому замена всё равно должна поместиться в уже имеющийся у участка лимит отправок. Запрос, который уже выдал вывод или вызов инструмента, не заменяется никогда, каким бы ни было это значение. Замещающий вывод модели всё равно может быть оплачен, если источник уже начал первый, поэтому параметр выключен по умолчанию. Общий путь перевода поддерживает замену при сбросе соединения до заголовков для первоначальных и заново сформированных отправок, используя то же разрешение и бюджет отправок. Транспорты, управляемые самими адаптерами, и сбои переведённого потока после получения заголовков от upstream исключены. | | `autoToolChoiceOnlyModels?` | `string[]` | Модели, у которых `tool_choice` принимает только `auto` или `none`; forced choice понижается. | diff --git a/docs-site/src/content/docs/tr/guides/providers.md b/docs-site/src/content/docs/tr/guides/providers.md index 237f1fb8793..3851ec43c6c 100644 --- a/docs-site/src/content/docs/tr/guides/providers.md +++ b/docs-site/src/content/docs/tr/guides/providers.md @@ -77,8 +77,9 @@ etiketler; bunlar normalde hem `authMode` hem de `apiKey`'i atlar. özelliği yalnızca API anahtarı sağlayıcıları için geçerlidir (`authMode: "key"`). OAuth, iletme ve yerel önayarlar hariç tutulur — kimlik bilgileri asla aynı belirteç üzerinde yeniden oynatılmamalıdır ve yerel çalışma zamanlarının -korunacak uzak bir anahtarı yoktur. İsteğe bağlıdır: seçenek olmadığında özellik -kapalıdır; nesnenin varlığı `enabled: false` olmadığı sürece etkinleştirir. +korunacak uzak bir anahtarı yoktur. Belgelenmiş hedefe özgü varsayılanlar dışında isteğe bağlıdır. Standart Mistral Chat, mevcut istek genelindeki gönderim hakkı içinde sabırlı bir yeniden deneme politikası (6 yeniden oynatma, 10 saniyelik yedek aralık) kullanır. `enabled: false` dahil açık `retryOn429` ayarları önceliklidir. Mistral önayarı, sağlanan istem önbelleği anahtarlarını da varsayılan olarak iletir; `promptCacheKey: false` iletmeyi kapatır. Önbellek yakınlığı yeniden kullanım olasılığını artırır, ancak önbellek isabetini garanti etmez ve belirteç verimi sınırlarını kaldırmaz. + +Mistral, hem Studio hem Vibe anahtarları için tek bir API anahtarı sağlayıcısı kullanır. Anahtar türleri ve plan hakları [Mistral'ın API anahtarı belgelerinde](https://docs.mistral.ai/admin/identity-access/api-keys) açıklanır; proxy anahtardan abonelik katmanı çıkarmaz. Tüm modellerde, yukarı akışın açıkça bildirdiği `Retry-After` süreleri aynı kimlik bilgisi ve model üzerindeki çağrılar arasında paylaşılır. Yeniden deneme zamanı içermeyen bir 429, geçerli dakikalık sayaçlar kapasitenin tükendiğini gösterdiğinde veya reddedilen sorgunun ölçülen maliyeti kalan belirteçleri aşıp ilan edilen tam hakka sığdığında temkinli bir 60 saniyelik kurtarma beklemesi başlatabilir. Bu, sunucu sıfırlamasının kesin bir tahmini değil, yerel bir kurtarma politikasıdır. Sağlıklı trafik, bir sonraki sorgunun önbelleğini veya maliyetini asla tahmin etmez. Beklemeden sonra tek bir kurtarma yoklaması çalışır; başarılı olursa normal trafik geri gelir. Eksik, hatalı biçimli, sıfır haklı, aylık hakkı tükenmiş veya tam hakkın üzerindeki kanıtlar bir sıfırlama uydurmaz ve geçmişi sıkıştırmaz. `requestPacing: { "enabled": false }` veya `retryOn429: { "respectRetryAfter": false }` paylaşılan hız ayarlamasını kapatır. Aynı anahtarla yeniden oynatmada, Mistral'ın yeniden deneme süresi `retryOn429.maxIntervalMs` değerini aşarsa proxy erken yeniden oynatmak yerine 429'u ve yeniden deneme ipucunu döndürür. Döngüsel (round-robin) anahtar seçimi etkinse bekleyen istekler anahtar soğuma sürelerini yeniden denetler ve yedek anahtar için hız kapasitesi alır; çıktıdan önceki bir anahtar reddi, isteğin mevcut gönderim sınırı içinde sağlıklı bir anahtarla kurtarılabilir. Bağlam pencereleri ve model başına sıkıştırma tercihleri bağımsız kalır. ## 1. ChatGPT girişi (iletme / doğrudan geçiş) @@ -422,6 +423,12 @@ yeniler ve etkin Chat Completions modellerini (ve yalnızca Responses ile sunulu [OpenGateway panelinde](https://opengateway.ai/api-keys) bir anahtar oluşturun, ardından `ocx provider add opengateway` çalıştırın veya panelde **OpenGateway** seçin. Chat istekleri yapılandırılmış Bearer anahtarını kullanır; genel model listesi anahtarı doğrulamaz. +Responses protokolünde OpenGateway, yeniden oynatılan yerel özel araç çağrılarını reddeder; bu nedenle ön ayar, kod modundaki `exec` gibi Codex özel araçlarını göndermeden önce işlev araçlarına dönüştürür. +Araç sonucu içerik dizileri metin olarak gönderilir; görseller eşleşen araç sonucu grubundan sonra korunur. Ret yanıtları ve opak içerik dahil desteklenmeyen sonuç parçaları, gönderimden önce açık bir istemci girdi hatasıyla reddedilir. +`original` görsel ayrıntısı, ağ geçidinin kabul ettiği `high` değerine eşlenir; görsel baytları değişmez. +Yerel Responses yolu, yalnızca metin içeren boş tamamlamaları başarı olarak sunmadan önce de denetler. +Tükenen çıktı bütçeleri `max_output_tokens` ile `incomplete` üretir; proxy istenen bütçeyi artırmaz. Bütçesi kalan ve güvenle yeniden oynatılabilen boş bir tamamlama, mevcut gönderim sınırı içinde bir kez yeniden denenebilir. Açık `emptyCompletionRetry: false` veya `OCX_EMPTY_COMPLETION_RETRY=0` bu yeniden denemeyi kapatır. Metin, ret yanıtları, araç etkinliği ve bilinmeyen akış olayları yeniden oynatmayı engeller ve özgün akışı korur. Bu koruma diğer sağlayıcıları ve onların yeniden deneme varsayılanlarını değiştirmez. +Yeniden oynatma açık `store: false`, eksiksiz girdi ve yalnızca istemcide çalışan araçlar gerektirir; `web_search` gibi barındırılan bir araç, hiçbir araç olayı gözlenmemiş olsa bile otomatik yeniden oynatmayı engeller. **OpenCode Zen** (`opencode-zen`), [opencode.ai/auth](https://opencode.ai/auth) üzerinden alınan API anahtarıyla `https://opencode.ai/zen/v1` kullanır. Ücretsiz modeller yaklaşık 15–20 istek/dakika kısa pencere sınırına takılabilir (topluluk ölçümü; OpenCode RPM yayımlamaz). Hız sınırı 429 yanıtında `Retry-After` yoksa opencodex açıklama ve sentetik `Retry-After` ekler; geçerli üst kaynak başlıkları önceliklidir. Aynı anahtarla yeniden deneme [`retryOn429`](/tr/reference/configuration/) ile isteğe bağlıdır. 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 b831a8bc166..15a8160c080 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -157,7 +157,8 @@ alanlı seçilmiş kimlikleri yalın kimliklere yeniden yazar. | `parallelToolCalls?` | `boolean` | Paralel araç çağrılarını açıp kapatın. OpenAI Chat varsayılan olarak açıktır; sohbet harici adaptörler yalnızca açık `true` durumunda bildirir. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | Tam yer tutucu kimlikleri, eksik terminal kimlikleri ve (`repairInvalidIds` ile) kurallı `msg_`/`rs_` öneki eksik olan mesaj/akıl yürütme kimlikleri için varsayılan olarak devre dışı bırakılmış aşağı akış SSE onarımı. Fonksiyon çağrısı kimlikleri asla yeniden yazılmaz. Yerleşik DeepSeek son ikisini varsayılan olarak etkinleştirir. | | `responsesSnapshotRepair?` | `boolean` | SSE ve JSON'daki seyrek Responses yaşam döngüsü anlık görüntüleri için varsayılan olarak devre dışı bırakılmış istemciye yönelik onarım. Ham inceleme ve kalıcılık değişmeden kalırken eksik kurallı durumu, çıktıyı ve araç meta verilerini doldurur. | -| `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | Yalnızca API anahtarı sağlayıcıları (`authMode: "key"`). İsteğe bağlı aynı hedef 429 yeniden denemesi: `retryOn429` olmadığında özellik kapalıdır; nesnenin varlığı `enabled: false` olmadığı sürece özelliği etkinleştirir. 429'da proxy bekler (yukarı akış `Retry-After` veya sabit aralık) ve herhangi bir anahtar yük devretmesinden önce aynı istek üzerinde aynı anahtarla aynı isteği yeniden oynatır — ana metin turu kurtarma döngüsü, Responses doğrudan geçiş hattı, görsel/video köprüsü, web araması sidecar'ı ve terminal devamları genelinde. Yalnızca akış öncesi HTTP 429 yanıtları yeniden oynatma için uygundur; özel `runTurn` aktarımları HTTP yeniden deneme döngüsünün dışındadır. `attempts`, ilk 429'dan sonraki aynı anahtar yeniden oynatmalarını sayar (toplam gönderim = `attempts` + 1) ve ana kurtarma döngüsü, terminal koruma devamı ve köprü yeniden denemeleri tarafından paylaşılan tek bir istek genelinde bütçedir. `attempts`'ı tüketmek yalnızca daha fazla aynı anahtar yeniden oynatmasını durdurur: normal anahtar yük devretmesi veya nihai hata işleme daha sonra kullanılabilir hedeflere göre geçerli olur — anahtar kimlik doğrulamalı doğrudan geçiş hattında yük devretme yoktur, bu nedenle tükenen 429 olduğu gibi görünür. Codex'in kendisi 429'u asla yeniden denemez, bu nedenle tek anahtarlı sağlayıcılar için tek savunma budur. Varsayılanlar: `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000` (tek bir bekleme `maxIntervalMs` ile sınırlandırılır, kendisi de 600000 ile sınırlandırılır), `respectRetryAfter: true`. | +| `promptCacheKey?` | `boolean` | `openai-chat` sağlayıcısı genelinde `prompt_cache_key` iletimini etkinleştirir. Adaptör aldığı anahtarı iletir, üretmez; ancak Claude Messages çevirisi `metadata.user_id` veya metadata yoksa model/sistem/araç grubundan anahtar türetebilir. Ön ayar belirtmedikçe varsayılan kapalıdır; Mistral [istem önbelleği sözleşmesine](https://docs.mistral.ai/studio/conversations/advanced/prompt-caching) göre açar, açık `false` kapatır. Yalnızca üst akış desteği belgeliyse açın; katı ağ geçitleri bilinmeyen alanı HTTP 400 ile reddedebilir. | +| `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | Yalnızca API anahtarlı sağlayıcılar (`authMode: "key"`). Ayar yoksa kapalıdır; kanonik OpenCode Go ve Command Code ile kanonik Mistral `openai-chat` istisnadır: 6 tekrar, 10 sn aralık, 60 sn tavan. Nesne verilmesi etkinleştirir; `enabled: false` bu varsayılanları da kapatır. Akış öncesi HTTP 429 durumunda `Retry-After` veya sabit aralık beklenir ve anahtar değişiminden önce aynı istek aynı anahtarla yeniden gönderilir. Metin turu kurtarma, Responses passthrough, görüntü/video köprüsü, web-search yan hizmeti ve terminal devamlarını kapsar; özel `runTurn` taşıması kapsam dışıdır. `attempts`, ilk 429 sonrasındaki aynı anahtar tekrarlarıdır (toplam gönderim = `attempts` + 1) ve istek genelinde paylaşılır. Tükenmesi yalnızca aynı anahtar tekrarını durdurur; normal anahtar değişimi veya son hata işleme sürer. Anahtarlı passthrough yolunda geçiş yoktur, 429 aynen döner. Kanonik Mistral Chat için açık üst akış gecikmesi izin verilen beklemeyi aşarsa aynı anahtar tekrarı durur ve özgün `Retry-After` korunur; havuzdaki başka anahtar yine hizmet verebilir. Diğer sağlayıcılarda gecikme tavana kırpılır. Mistral, kimlik bilgisi/model başına son tarihleri paylaşır; `requestPacing.enabled: false` veya `retryOn429.respectRetryAfter: false` bunu kapatır. Tekrar zamanı olmayan 429 ve doğrulanmış dakika bütçesi baskısı, temkinli 60 sn bekleme ve ardından tek kurtarma denemesi başlatır; başarı olağan trafiği geri getirir. Sağlıklı trafik sonraki token maliyetini tahmin etmez. Bildirilen kotalar kullanılır; tükendiği bilinen aylık kotalar hariçtir, kesin sıfırlama zamanı vaat edilmez ve geçmiş sıkıştırılmaz. Codex 429’u kendisi yeniden denemez. Nesne varsayılanları: `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000` (bekleme tavanı, ayar en fazla 600000), `respectRetryAfter: true`. | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | Yalnızca anahtarla kimlik doğrulanan `openai-chat` ve `openai-responses` sağlayıcıları. `authMode: "forward"` sağlayıcıları (ChatGPT hesap havuzu) bu seçeneği hiç okumaz ve varsayılan merdiveni korur. Akış öncesi geçici yukarı akış durumları (500, 502, 503, 504, 520, 521, 522) için isteğe bağlı yeniden deneme: seçenek belirtilmezse kapalıdır; nesnenin varlığı, `enabled: false` olmadığı sürece özelliği etkinleştirir. İlk Responses isteğini, terminal koruma devamını, yerel `/v1/chat/completions` isteklerini ve 429/hesap kurtarma yeniden getirmelerini kapsar. `attempts`, bir istek için ilk gönderim dahil izin verilen yukarı akış gönderimlerinin TOPLAM sayısıdır (1..10, varsayılan 3) — bağlantı sıfırlama kurtarmasıyla paylaşılan, istek kapsamlı tek bütçedir; dolayısıyla `3`, sağlayıcıya en fazla üç gerçek isteğin ulaşması anlamına gelir. Beklemelerde 400 ms'lik sabit üstel geri çekilme uygulanır, süre 5 sn ile sınırlandırılır ve `Retry-After` dikkate alınır. Hız sınırlamasını işleyen `retryOn429` seçeneğinden ayrıdır; akış ortası hataları hiçbir zaman yeniden oynatılmaz. Her Combo hedefinde önceden ayrılmış ilk gönderim, o hedefin yapılandırılmış toplamına dahildir: `attempts: 1` tek gönderim yapar; rezervasyonu geri vererek ek hak yaratmaz ve ortak tavanı artırmaz. Gönderim öncesi yerel ret veya iptal, kullanılmamış rezervasyonu serbest bırakır. | | `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | Yerel `openai-responses` sağlayıcıları (`authMode: "forward"` dahil) ve upstream başlıkları gelmeden önceki genel Responses çeviri gönderim yolu için geçerlidir. Çağıranın hiçbir şey gözlemlemediği bir anda başarısız olan gönderimin isteğe bağlı olarak değiştirilmesi: seçenek belirtilmezse kapalıdır; nesnenin varlığı, `enabled: false` olmadığı sürece özelliği etkinleştirir. İki belirsiz aşamayı da kapsar: yanıt başlığı gelmeden kopan bağlantı ve başlıktan sonra yalnızca denetim olayları taşırken kopan SSE gövdesi. Kanonik ChatGPT upstream WebSocket'i create çerçevesi gönderildikten sonra, herhangi bir Responses olayından önce kapanır veya hata verirse aynı şekilde kapsanır ve değiştirme gönderimi HTTP üzerinden yapılır. Yalnızca kendi kendine yeten bir istek değiştirilir: `store: false`, eksiksiz `input`, `previous_response_id`, `conversation` veya `stream_id` bulunmaması ve yalnızca istemcinin yürüttüğü araçlar. `replacements`, BİR mantıksal isteğin tüm bacaklar ve tüm combo alt istekleri boyunca yapabileceği değiştirme gönderimi sayısıdır (1..2, varsayılan 1). Bacak başına yeniden deneme sayısı da gönderim bütçesi de değildir; bu yüzden bir değiştirme gönderimi, ilgili bacağın hâlihazırda sahip olduğu gönderim payına sığmak zorundadır. Halihazırda çıktı veya araç çağrısı üretmiş bir istek, bu değer ne olursa olsun asla değiştirilmez. Kaynak ilk çıkarımı zaten başlatmışsa değiştirilen çıkarım yine ücretlendirilebilir; bu nedenle varsayılan olarak kapalıdır. Genel çeviri gönderim yolu, ilk ve yeniden oluşturulan gönderimlerde başlık öncesi bağlantı sıfırlamasından sonra aynı izin ve gönderim bütçesiyle değiştirme destekler. Adaptörlerin kendi yönettiği taşıma yolları ve upstream başlıklarından sonraki çevrilmiş akış hataları kapsam dışıdır. | | `autoToolChoiceOnlyModels?` | `string[]` | `tool_choice`'u yalnızca `auto` veya `none` kabul eden modeller; zorunlu seçimlerin derecesi düşürülür. | diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index e5a9e9fb5a3..89b0b8d3f3d 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -52,7 +52,9 @@ shipped v1 配置自动迁移到 marker 2 的单一选项行。原配置只保 [`retryOn429`](/zh-cn/reference/configuration/)(同 key 的 429 重试)仅适用于 API-key 提供商 (`authMode: "key"`)。OAuth、forward 与本地预设均被排除——同一 token 绝不可重放,本地运行时 -也没有需要保留的远程 key。仅在配置后启用,默认关闭;配置了对象即启用,除非 `enabled: false`。 +也没有需要保留的远程 key。除文档中说明的特定目标默认值外,此功能需手动启用。规范的 Mistral Chat 在现有的按请求发送额度内使用耐心重试策略(6 次重放,10 秒回退间隔)。显式的 `retryOn429` 设置(包括 `enabled: false`)优先。Mistral 预设还会默认转发传入的 prompt 缓存 key;`promptCacheKey: false` 可关闭转发。缓存亲和性会提高复用几率,但不保证命中缓存,也不会消除 token 吞吐量限制。 + +Mistral 的 Studio 和 Vibe 密钥共用同一个 API-key 提供商。密钥类型和套餐额度见 [Mistral 的 API 密钥文档](https://docs.mistral.ai/admin/identity-access/api-keys);代理不会根据密钥推断订阅等级。对所有模型,上游显式给出的 `Retry-After` 截止时间会在同一凭据与模型的调用之间共享。没有重试时间的 429,在有效的每分钟计数器显示容量已耗尽,或被拒查询的实测成本超过剩余 token 但仍在公布的完整额度内时,可能触发保守的 60 秒恢复等待。这是本地恢复策略,并非对服务端重置时间的精确预测。正常流量下从不预测下一次查询的缓存或成本。等待结束后发送一次恢复探测;成功即恢复正常流量。缺失、格式错误、零授权、月度额度已耗尽或超过完整额度的依据,不会凭空推断重置,也不会压缩历史。`requestPacing: { "enabled": false }` 或 `retryOn429: { "respectRetryAfter": false }` 可关闭共享节流。对于同一密钥的重放,若 Mistral 的重试时间超过 `retryOn429.maxIntervalMs`,代理会返回 429 及重试提示,而不会提前重放。启用轮询(round-robin)密钥选择时,排队的请求会重新检查密钥冷却并为替代密钥获取节流容量;输出前的密钥拒绝可以在该请求现有的发送上限内切换到健康的密钥恢复。上下文窗口和按模型的压缩偏好保持独立。 ## 1. ChatGPT 登录(forward / 透传) @@ -291,6 +293,12 @@ Sionic 提供的 `deepseek/deepseek-v4.1-flash-ultrafast` 和 [OpenGateway 控制台](https://opengateway.ai/api-keys)创建密钥,再运行 `ocx provider add opengateway` 或在控制台选择 **OpenGateway**。Chat 请求使用配置的 Bearer 密钥;公开模型列表不能验证密钥有效性。 +在 Responses 协议中,OpenGateway 拒绝重放原生自定义工具调用,因此预设会在发送前将代码模式的 `exec` 等 Codex 自定义工具转换为函数工具。 +工具结果内容数组以文本发送,图像保留在配对的工具结果组之后。不支持的结果部分(包括拒绝和不透明内容)会在发送前以明确的客户端输入错误拒绝。 +图像细节 `original` 映射为网关接受的 `high`;图像字节保持不变。 +原生 Responses 路由还会在将纯文本空补全作为成功返回之前进行检查。 +输出预算耗尽时返回带 `max_output_tokens` 的 `incomplete`;代理不会增加请求的预算。仍有预算且可安全重放的空补全,可在现有发送上限内重试一次。显式设置 `emptyCompletionRetry: false` 或 `OCX_EMPTY_COMPLETION_RETRY=0` 可关闭该重试。出现文本、拒绝、工具活动或未知流事件时不会重放,并保留原始流。此保护不改变其他提供商及其重试默认值。 +重放要求显式 `store: false`、完整输入且仅含客户端执行的工具;声明了 `web_search` 等托管工具时,即使未观察到工具事件也不会自动重放。 MiniMax 和 MiniMax (CN) 的提供商卡片也会在配置的密钥有有效 Coding Plan 时显示用量。 仪表盘读取 5 小时窗口,以及套餐提供时的每周窗口;这些仅用于展示,不会改变模型路由。 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 c943c306b79..6a26f9cab26 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 @@ -147,7 +147,8 @@ selector,而不是分配一个新名称。 | `parallelToolCalls?` | `boolean` | 切换并行工具调用。OpenAI Chat 默认开启;非 chat 适配器只有显式 `true` 时才会声明支持。 | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | 默认关闭的下游 SSE 修复,用于精确占位 id、缺失的终止 id,以及(`repairInvalidIds`)缺少规范 `msg_`/`rs_` 前缀的 message/reasoning id。function-call id 永远不会被重写。内置 DeepSeek 默认启用后两项。 | | `responsesSnapshotRepair?` | `boolean` | 默认关闭的客户端修复,用于补全 SSE 与 JSON 中稀疏 Responses 生命周期快照缺失的 status、output 和工具元数据;原始检查与持久化保持不变。 | -| `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | 仅限 API-key 提供商(`authMode: "key"`)。可选的同目标 429 重试:未配置 `retryOn429` 时功能关闭;对象存在即启用,除非 `enabled: false`。收到 429 时等待(上游 `Retry-After` 或固定间隔)后在相同 key 上重放完全相同请求,再进入任何 key 故障转移——覆盖主文本恢复循环、Responses passthrough、图像/视频桥、web-search 侧车与终结续接。重放仅适用于流开始前的 HTTP 429 响应;自定义 `runTurn` 传输不在 HTTP 重试循环范围内。`attempts` 是首个 429 之后的同 key 重放次数(总发送次数 = `attempts` + 1),是主恢复循环、终结守卫续接与桥接重试共享的按请求统一预算;`attempts` 耗尽只会停止进一步的同 key 重放:随后按可用目标进行正常的 key 故障转移或最终错误处理——key 认证的 passthrough 线路上没有故障转移,因此耗尽的 429 会原样透出。Codex 自身从不重试 429,因此这是单 key 提供商唯一的防线。默认值:`enabled: true`、`attempts: 3`、`intervalMs: 5000`、`maxIntervalMs: 60000`(单次等待以 `maxIntervalMs` 为上限,其本身上限 600000)、`respectRetryAfter: true`。 | +| `promptCacheKey?` | `boolean` | 为整个 `openai-chat` 提供商启用 `prompt_cache_key` 转发。适配器只转发收到的密钥,不自行生成,但 Claude Messages 转换会从 `metadata.user_id`,或在没有元数据时从模型/系统/工具组合派生密钥。除非预设声明,否则默认关闭;Mistral 按其[提示缓存契约](https://docs.mistral.ai/studio/conversations/advanced/prompt-caching)启用,显式 `false` 可关闭。仅在上游明确支持时启用,严格网关可能以 HTTP 400 拒绝未知字段。 | +| `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | 仅限 API-key 提供商(`authMode: "key"`)。未配置时关闭,但规范端点的 OpenCode Go 和 Command Code,以及规范 Mistral 的 `openai-chat` 例外:默认重放 6 次、间隔 10 秒、上限 60 秒。配置对象即启用;`enabled: false` 也会关闭该默认策略。流开始前的 HTTP 429 按 `Retry-After` 或固定间隔等待,然后在密钥故障转移之前用同一密钥重放同一请求。覆盖文本轮次恢复、Responses passthrough、图像/视频桥、web-search 侧车和终结续接;自定义 `runTurn` 不在范围内。`attempts` 统计首个 429 后的同密钥重放次数(总发送次数 = `attempts` + 1),预算由整个请求共享。耗尽时仅停止同密钥重放,随后按可用目标执行正常密钥故障转移或最终错误处理;密钥认证 passthrough 无故障转移,会原样返回 429。规范 Mistral Chat 的显式上游延迟超过允许等待时间时,会停止同密钥重放并保留原始 `Retry-After`;池中另一密钥仍可能处理请求。其他提供商仍将等待截断至上限。Mistral 按凭据/模型共享重试截止时间;`requestPacing.enabled: false` 或 `retryOn429.respectRetryAfter: false` 可关闭。无重试时间的 429 在确认每分钟预算不足时触发保守的 60 秒恢复等待,随后仅探测一次,成功即恢复正常流量。正常流量不预测下一次 token 成本。策略使用公布的额度,排除已知耗尽的月度额度,不承诺精确重置时间,也不触发历史压缩。Codex 本身不会重试 429。对象默认值:`enabled: true`、`attempts: 3`、`intervalMs: 5000`、`maxIntervalMs: 60000`(单次等待上限,配置最大 600000)、`respectRetryAfter: true`。 | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | 仅限使用 key 认证的 `openai-chat` 与 `openai-responses` 提供商。`authMode: "forward"` 的提供商(ChatGPT 账号池)从不读取此选项,保持默认重试次数。可选的流开始前上游瞬态状态码(500、502、503、504、520、521、522)重试:未配置时关闭;对象存在即启用,除非 `enabled: false`。覆盖初始 Responses 请求、终结守卫续接、原生 `/v1/chat/completions`,以及 429/账户恢复重新获取。`attempts` 是单个请求允许向上游发送的总次数,包含首次发送(1..10,默认 3);它是与连接重置恢复共享的按请求预算,因此 `3` 表示最多只有三个实际请求到达提供商。等待采用固定 400 毫秒的指数退避,上限为 5 秒,并遵循 `Retry-After`。此选项独立于处理速率限制的 `retryOn429`;流开始后的故障绝不会重放。 每个 Combo 目标都把预先预约的首次发送计入该目标配置的总次数;`attempts: 1` 只发送一次,不会退还预约来增加额度,也不会提高共享上限。发送前的本地拒绝或取消会释放未使用的预约。 | | `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | 适用于原生 `openai-responses` 提供商(包含 `authMode: "forward"`),以及收到上游响应头之前的通用 Responses 转换发送路径。可选地替换一次在调用方尚未观察到任何内容时就失败的发送:未配置时关闭;对象存在即启用,除非 `enabled: false`。涵盖两个不确定阶段——响应头到达前连接断开,以及响应头之后 SSE 正文只承载控制事件时断开。canonical ChatGPT 上游 WebSocket 在 create 帧发出之后、任何 Responses 事件到达之前关闭或出错时,也按同样方式处理,其替换发送走 HTTP。只有自包含的请求才会被替换:`store: false`、完整的 `input`、没有 `previous_response_id`/`conversation`/`stream_id`,且只使用由客户端执行的工具。`replacements` 是单个逻辑请求在所有环节和所有组合子请求中可以进行的替换发送次数(1..2,默认 1);它既不是按环节的重试次数,也不是发送预算,因此替换发送仍必须落在该环节已有的发送额度之内。已经产生输出或工具调用的请求,无论此值为何都不会被替换。如果上游已经开始了第一次推理,被替换的推理仍可能计费,因此该选项默认关闭。 通用转换发送路径的首次发送和重建后的发送都支持响应头之前连接重置后的替换,使用同一授权和发送预算。适配器自行管理的传输,以及收到上游响应头之后的转换流故障,均不在此选项范围内。 | | `autoToolChoiceOnlyModels?` | `string[]` | `tool_choice` 只接受 `auto` 或 `none` 的模型;强制选择会被降级。 | diff --git a/docs-site/src/content/docs/zh-tw/guides/providers.md b/docs-site/src/content/docs/zh-tw/guides/providers.md index 444863f7c8e..2fd7c0749dc 100644 --- a/docs-site/src/content/docs/zh-tw/guides/providers.md +++ b/docs-site/src/content/docs/zh-tw/guides/providers.md @@ -56,8 +56,9 @@ preset 通常同時省略 `authMode` 與 `apiKey`。 [`retryOn429`](/zh-tw/reference/configuration/) 的 same-key 429 replay 只適用於 API-key provider (`authMode: "key"`)。OAuth、forward 與 local preset 都被排除:它們的 credential 絕不能在同一 token -上重播,而 local runtime 也沒有 remote key 可保留。此功能為 opt-in;未設定時關閉,物件存在時預設 -啟用,除非明確設為 `enabled: false`。 +上重播,而 local runtime 也沒有 remote key 可保留。除了文件記載的特定目的地預設值之外,此功能為 opt-in。標準 Mistral Chat 會在既有的整個請求傳送額度內使用耐心的重試策略(6 次 replay,10 秒備援間隔)。明確的 `retryOn429` 設定(包括 `enabled: false`)優先。Mistral preset 也會預設轉送提供的 prompt cache key;`promptCacheKey: false` 可停用轉送。快取親和性能提高重用機會,但不保證快取命中,也不會移除 token 吞吐量限制。 + +Mistral 的 Studio 與 Vibe key 共用同一個 API-key provider。key 類型與方案額度請見 [Mistral 的 API key 文件](https://docs.mistral.ai/admin/identity-access/api-keys);proxy 不會從 key 推斷訂閱等級。對所有模型,上游明確給出的 `Retry-After` 期限會在同一 credential 與模型的呼叫之間共用。沒有重試時間的 429,在有效的每分鐘計數器顯示容量已耗盡,或被拒查詢的實測成本超過剩餘 token 但仍在公告的完整額度內時,可能啟動保守的 60 秒復原等待。這是本地復原策略,並非對伺服器重設時間的精確預測。正常流量從不預測下一次查詢的快取或成本。等待後會執行一次復原探測;成功即恢復一般流量。缺少、格式錯誤、零授權、月額度已耗盡或超過完整額度的依據,不會憑空推斷重設,也不會壓縮歷史。`requestPacing: { "enabled": false }` 或 `retryOn429: { "respectRetryAfter": false }` 可停用共用節流。對於同一 key 的 replay,若 Mistral 的重試時間超過 `retryOn429.maxIntervalMs`,proxy 會回傳 429 與重試提示,而不會提早 replay。啟用 round-robin key 選擇時,排隊中的請求會重新檢查 key 冷卻並為替代 key 取得節流容量;輸出前的 key 拒絕可在該請求既有的送出上限內改用健康的 key 復原。context window 與各模型的壓縮偏好維持獨立。 ## 1. ChatGPT 登入(forward / passthrough) @@ -336,6 +337,12 @@ Sionic 提供的 `deepseek/deepseek-v4.1-flash-ultrafast` 與 [OpenGateway 控制台](https://opengateway.ai/api-keys)建立金鑰,再執行 `ocx provider add opengateway` 或在控制台選擇 **OpenGateway**。Chat 請求使用設定的 Bearer 金鑰;公開模型清單無法驗證金鑰有效性。 +在 Responses 協定中,OpenGateway 拒絕 replay 原生自訂工具呼叫,因此 preset 會在傳送前將程式碼模式的 `exec` 等 Codex 自訂工具轉換為函式工具。 +工具結果內容陣列以文字傳送,圖片保留在配對的工具結果群組之後。不支援的結果部分(包括拒絕與不透明內容)會在傳送前以明確的用戶端輸入錯誤拒絕。 +圖片細節 `original` 對應為閘道接受的 `high`;圖片位元組維持不變。 +原生 Responses 路由也會在將純文字空完成以成功回傳之前先檢查。 +輸出預算耗盡時回傳帶 `max_output_tokens` 的 `incomplete`;proxy 不會提高請求的預算。仍有預算且可安全 replay 的空完成,可在既有傳送上限內重試一次。明確設定 `emptyCompletionRetry: false` 或 `OCX_EMPTY_COMPLETION_RETRY=0` 可停用此重試。出現文字、拒絕、工具活動或未知串流事件時不會 replay,並保留原始串流。此保護不會改變其他 provider 及其重試預設值。 +Replay 需要明確的 `store: false`、完整輸入且僅含用戶端執行的工具;宣告了 `web_search` 等託管工具時,即使未觀察到工具事件也不會自動 replay。 **OpenCode Zen**(`opencode-zen`)使用 [opencode.ai/auth](https://opencode.ai/auth) 核發的 API key 存取 `https://opencode.ai/zen/v1`。免費模型可能遇到每分鐘約 15–20 次請求的短時間速率限制(社群測量;OpenCode 未公布 RPM)。速率限制 429 缺少 `Retry-After` 時,opencodex 會加入說明與合成的 `Retry-After`;有效的上游 header 優先。同 key 重試仍須透過 [`retryOn429`](/zh-tw/reference/configuration/) 啟用。 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 03644d6f497..183cdf3a409 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 @@ -119,6 +119,8 @@ ocx models provider openrouter on | `foldDeveloperRoleToSystem?` | `boolean` | 記錄某個 `openai-chat` 目的地是否接受 `developer` 角色。`foldDeveloperRoleToSystem` 未設定時以 `system` 傳送,`true` 時以 `system` 傳送,`false` 時以 `developer` 傳送。未設定表示尚未記錄該目的地的情況;`true` 記錄上游拒絕該角色;`false` 記錄其接受該角色。無論何者,訊息都保留在對話中的原有位置,只有角色改變。拒絕該角色的目的地會回應 `400 role 'developer' is not allowed`,該回合根本無法開始,這就是未記錄狀態預設摺疊的原因。 | | `parallelToolCalls?` | `boolean` | 切換平行工具呼叫。OpenAI Chat 預設開啟;非 chat adapter 僅在明確 `true` 時廣告。 | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean }` | 預設停用的下游 SSE 修復,用於精確佔位 id 與缺失的終端 id。Function-call id 永不被重寫。 | +| `promptCacheKey?` | `boolean` | 為整個 `openai-chat` provider 啟用 `prompt_cache_key` 轉送。adapter 只轉送收到的 key,不自行產生,但 Claude Messages 轉換會從 `metadata.user_id`,或在沒有 metadata 時從模型/系統/工具組合衍生 key。除非 preset 宣告,否則預設關閉;Mistral 依其[提示快取契約](https://docs.mistral.ai/studio/conversations/advanced/prompt-caching)啟用,明確的 `false` 可停用。僅在上游明確支援時啟用,嚴格閘道可能以 HTTP 400 拒絕未知欄位。 | +| `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | 僅限 API-key provider(`authMode: "key"`)。未設定時關閉,但標準端點的 OpenCode Go 與 Command Code,以及標準 Mistral 的 `openai-chat` 例外:預設 replay 6 次、間隔 10 秒、上限 60 秒。設定物件即啟用;`enabled: false` 也會停用該預設策略。串流開始前的 HTTP 429 會依 `Retry-After` 或固定間隔等待,然後在 key 容錯移轉前以同一 key 重播相同請求。涵蓋文字回合復原、Responses passthrough、圖片/影片橋接、web-search 輔助服務及終端續接;自訂 `runTurn` 不在範圍內。`attempts` 計算第一個 429 後的同 key replay 次數(總傳送次數 = `attempts` + 1),預算由整個請求共用。耗盡時僅停止同 key replay,接著依可用目的地執行一般 key 容錯移轉或最終錯誤處理;金鑰認證 passthrough 沒有容錯移轉,會原樣回傳 429。標準 Mistral Chat 的明確上游延遲超過允許等待時間時,停止同 key replay 並保留原始 `Retry-After`;pool 中另一個 key 仍可能處理請求。其他 provider 仍將等待截斷至上限。Mistral 依 credential/模型共用重試期限;`requestPacing.enabled: false` 或 `retryOn429.respectRetryAfter: false` 可停用。沒有重試時間的 429 在確認每分鐘預算不足時觸發保守的 60 秒復原等待,隨後只探測一次,成功即恢復一般流量。正常流量不預測下一次 token 成本。策略使用公告額度,排除已知耗盡的月額度,不承諾精確重設時間,也不觸發歷史壓縮。Codex 本身不重試 429。物件預設值:`enabled: true`、`attempts: 3`、`intervalMs: 5000`、`maxIntervalMs: 60000`(單次等待上限,設定最大 600000)、`respectRetryAfter: true`。 | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | 僅限使用金鑰認證的 `openai-chat` 與 `openai-responses` 供應商。`authMode: "forward"` 的供應商(ChatGPT 帳號池)從不讀取此選項,維持預設重試次數。選擇性重試串流開始前的暫時性上游狀態(500、502、503、504、520、521、522):未設定時停用;只要有此物件即啟用,除非 `enabled: false`。涵蓋初始 `Responses` 請求、終止防護續接、原生 `/v1/chat/completions`,以及 429/帳號復原的重新擷取。`attempts` 是單一請求允許傳送至上游的總次數,包含第一次(1..10,預設 3);這是與連線重設復原共用的單一請求範圍預算,因此 `3` 表示最多只有三個實際請求會送達供應商。等待採固定 400 毫秒、上限 5 秒的指數退避,並遵循 `Retry-After`。此機制獨立於處理速率限制的 `retryOn429`;串流中的失敗絕不重播。 每個 Combo 目標都把預先預約的首次傳送計入該目標設定的總次數;`attempts: 1` 只傳送一次,不會退還預約來增加額度,也不會提高共用上限。傳送前的本機拒絕或取消會釋放未使用的預約。 | | `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | 適用於原生 `openai-responses` 供應商(包含 `authMode: "forward"`),以及收到上游回應標頭之前的通用 Responses 轉換傳送路徑。可選擇性地替換一次在呼叫端尚未觀察到任何內容時就失敗的傳送:未設定時停用;只要有此物件即啟用,除非 `enabled: false`。涵蓋兩個不確定階段——回應標頭抵達前連線中斷,以及標頭之後 SSE 內文只載有控制事件時中斷。canonical ChatGPT upstream WebSocket 在 create 訊框送出之後、任何 Responses 事件抵達之前關閉或出錯時,也以相同方式處理,其替換傳送改走 HTTP。只有自我完備的請求才會被替換:`store: false`、完整的 `input`、沒有 `previous_response_id`/`conversation`/`stream_id`,且僅使用由用戶端執行的工具。`replacements` 是單一邏輯請求在所有環節與所有組合子請求中可進行的替換傳送次數(1..2,預設 1);它既不是各環節的重試次數,也不是傳送預算,因此替換傳送仍必須落在該環節既有的傳送額度之內。已經產生輸出或工具呼叫的請求,無論此值為何都不會被替換。若上游已經開始第一次推論,被替換的推論仍可能計費,因此此選項預設停用。 通用轉換傳送路徑的首次傳送和重建後的傳送都支援標頭之前連線重設後的替換,使用同一授權和傳送預算。配接器自行管理的傳輸,以及收到上游回應標頭之後的轉換串流故障,均不在此選項範圍內。 | | `autoToolChoiceOnlyModels?` | `string[]` | 其 `tool_choice` 僅接受 `auto` 或 `none` 的模型;強制選擇被降級。 | From e01c5a7794d24a136817a191a821ef34965e27fa Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 24 Oct 2026 21:42:21 +0900 Subject: [PATCH 014/101] fix(command-code): rotate on insufficient-credit refusals (#7558) * fix(command-code): rotate on insufficient-credit refusals Command Code reports exhausted credits as HTTP 400/BAD_REQUEST, which bypasses the generic OAuth 429 rotation path. Normalize only the exact refusal to 429 while preserving the original body and status in headers. * fix(command-code): require the literal credit refusal prefix CodeRabbit noted the regex also accepted near-miss messages. Require the exact upstream prefix and cover the near misses. * test(command-code): assert the retry response body and status Cover the normalized retry response's original 400 status and preserved JSON body, as requested in review. * fix(command-code): fail open on malformed UTF-8 refusals Review noted that non-fatal UTF-8 decoding could still classify a malformed 400 body as an insufficient-credit refusal. Decode fatally so the existing fail-open path preserves the original response. * fix(command-code): address review on credit-refusal handling - Re-throw the caller's abort reason from refusal inspection. - Register the new test in both layout maps and keep layout.json below the file-size ratchet. - Document the narrow 400-to-429 normalization in the adapter contract. - Cover the abort path with a focused test. * test(command-code): keep upstream 429 and 402 out of credit classification A genuine upstream 429 must keep its own Retry-After and reach rotation unlabelled, and a 402 stays a 402 even when its body repeats the credit wording. Pin both so the narrow 400 normalization cannot widen. Co-authored-by: Yushi Chen * test(command-code): drive the credit refusal through dispatch rotation With two stored Command Code accounts, the exact credit refusal on the first account now completes the same request on the sibling, while an ordinary BAD_REQUEST stays on the account and surfaces as a 400. The rotation case fails against the adapter without the normalization. Co-authored-by: Yushi Chen * fix(command-code): rewrite the credit refusal only on OAuth rows A canonical key-auth Command Code row gets a same-key 429 retry ladder from rateLimitRetryPolicyFor, so rewriting its permanent credit 400 into a 429 would replay a credential with no credits left before surfacing the error. Only OAuth rows have sibling accounts to rotate to, so key and forward rows now keep the original 400. Co-authored-by: Yushi Chen --------- Co-authored-by: Yushi Chen --- scripts/test-layout/layout.json | 2 +- src/adapters/command-code.ts | 39 +++- structure/providers-and-adapters.md | 2 +- tests/fixtures/test-layout-expected.json | 3 +- .../command-code-credit-failover.test.ts | 198 ++++++++++++++++++ .../command-code-credit-rotation.test.ts | 90 ++++++++ 6 files changed, 328 insertions(+), 6 deletions(-) create mode 100644 tests/providers/command-code-credit-failover.test.ts create mode 100644 tests/providers/command-code-credit-rotation.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 8d1496c0842..647d679cb2b 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -133,7 +133,7 @@ "responses-grok-devin-preflight.test.ts": "responses", "deepseek-quota-currency.test.ts": "providers", "mimo-token-plan-capacity.test.ts": "providers", - "command-code-tool-text-prose-split.test.ts": "providers", + "command-code-tool-text-prose-split.test.ts": "providers", "command-code-credit-failover.test.ts": "providers", "command-code-credit-rotation.test.ts": "providers", "cli-effort-slug.test.ts": "cli", "grok-47-build-fast-metadata.test.ts": "providers/xai", "grok-47-fast-model.test.ts": "providers/xai", diff --git a/src/adapters/command-code.ts b/src/adapters/command-code.ts index 87e233f6d43..b6502297e6c 100644 --- a/src/adapters/command-code.ts +++ b/src/adapters/command-code.ts @@ -493,6 +493,35 @@ function isReasoningEffortRejection(status: number, payload: string): boolean { return (status === 400 || status === 422) && /reasoning[_ -]?effort|unsupported effort|invalid effort/i.test(payload); } +/** Command Code reports exhausted credits as BAD_REQUEST; normalize only that refusal + * so every caller can reuse the existing OAuth quota rotation and send budget. Key and + * forward rows keep the 400: a canonical key row has a same-key 429 retry ladder + * (`rateLimitRetryPolicyFor`) that would replay a credential whose credits are gone. */ +async function normalizeCommandCodeCreditRefusal( + response: Response, oauth: boolean, signal?: AbortSignal, +): Promise { + if (!oauth || response.status !== 400) return response; + try { + const observed = await readBoundedResponseBody(response.clone(), { + signal, + maxBytes: 8 * 1024, + fatalUtf8: true, + }); + if (!observed.displaySafe) return response; + const payload = JSON.parse(observed.text); + if (payload?.success !== false || payload.error?.code !== "BAD_REQUEST" + || typeof payload.error?.message !== "string" + || !payload.error.message.startsWith("You have insufficient credits to make this request.")) return response; + const headers = new Headers(response.headers); + headers.set("x-opencodex-upstream-status", "400"); + headers.set("x-opencodex-refusal", "insufficient-credits"); + return new Response(response.body, { status: 429, statusText: "Too Many Requests", headers }); + } catch (error) { + if (signal?.aborted && error === signal.reason) throw error; + return response; + } +} + function requestWithoutReasoningEffort(request: AdapterRequest): AdapterRequest | undefined { try { const body = JSON.parse(request.body) as { params?: Record }; @@ -589,6 +618,8 @@ function supportedCommandCodeEffort(provider: OcxProviderConfig, modelId: string export function createCommandCodeAdapter(provider: OcxProviderConfig): ProviderAdapter { const executor = (provider as OcxProviderConfig & { fetch?: typeof globalThis.fetch }).fetch ?? globalThis.fetch; + // Only an OAuth row has sibling accounts for the rewritten 429 to rotate to. + const creditRefusalRotates = provider.authMode === "oauth"; // The server builds one adapter per routed request and hands parseStream a guarded wrapper // rather than the Response fetchResponse returned, so the stream reads the declared catalog of // the request this instance last built. A parser with no built request restores nothing. @@ -651,7 +682,9 @@ export function createCommandCodeAdapter(provider: OcxProviderConfig): ProviderA }, async fetchResponse(request: AdapterRequest, ctx?: AdapterFetchContext): Promise { const send = createAdapterPhysicalSend(ctx, executor); - const response = await send({ url: request.url, dispatch: physical => fetchCommandCode(request, ctx, physical) }); + const response = await normalizeCommandCodeCreditRefusal( + await send({ url: request.url, dispatch: physical => fetchCommandCode(request, ctx, physical) }), creditRefusalRotates, ctx?.abortSignal, + ); if (response.ok) return response; const currentEffort = (() => { try { return (JSON.parse(request.body) as { params?: { reasoning_effort?: unknown } }).params?.reasoning_effort; } catch { return undefined; } @@ -678,9 +711,9 @@ export function createCommandCodeAdapter(provider: OcxProviderConfig): ProviderA const retry = requestWithoutReasoningEffort(request); if (!retry) return response; try { - return await send({ url: retry.url, sendClass: "repair", recovery: "reasoning-effort-downgrade", + return await normalizeCommandCodeCreditRefusal(await send({ url: retry.url, sendClass: "repair", recovery: "reasoning-effort-downgrade", beforeDispatch: () => { try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ } }, - dispatch: physical => fetchCommandCode(retry, ctx, physical) }); + dispatch: physical => fetchCommandCode(retry, ctx, physical) }), creditRefusalRotates, ctx?.abortSignal); } catch (error) { if (error instanceof SendBudgetExhaustedError) return response; throw error; diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index d5d24f148bc..640e05d5a1c 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -212,7 +212,7 @@ rewrite rules and the routed-id settlement. | `src/adapters/devin.ts`, `src/adapters/devin/cloud-direct/` | Devin runTurn transport over Cognition Connect-RPC. Assistant reasoning replays as ChatMessagePrompt #11 thinking, #12 signature and #18 signature type (`src/adapters/devin/reasoning-signature.ts`): the #10/#21 pair arrives after the visible answer and becomes its own signature-only reasoning item, so a single unsigned thinking block plus exactly one signature-only block is replayed as one signed prompt, and a signature-only turn (GPT, Gemini) is replayed rather than dropped. An Anthropic signature is replayed, but because the streamed thinking is a summary the signature may not cover, a turn Cognition refuses with `invalid_argument` before any visible output (reasoning alone does not count) is retried once with Anthropic signatures withheld and the thinking text kept. `GetChatMessage` uses the Responses provider executor and shared physical-send budget; catalog, JWT, and `src/web-search/devin-executor.ts` native search support RPCs remain outside inference-send accounting. Provider-stated pre-output 429 reset delays are surfaced immediately by default, releasing shared active-turn capacity. A positive `OPENCODEX_DEVIN_STATED_RESET_WAIT_MS` explicitly enables bounded waiting and up to two replays on standalone turns, which hold that capacity until completion or cancellation. Combo children bypass that wait and surface a pre-output 429 so the next target can run. During an opted-in standalone wait, safe heartbeats commit the response preflight and keep the stream's stall watchdog fed. Invalid values fail closed to the immediate-refusal behavior. A recorded tenant host is used only for the stored account whose credential owns the transmitted key, searched in the configured provider id and then its deprecated alias; a configured, forwarded, or unmatched key uses the configured base URL or the US default. Native search previews the current route by effective adapter without mutating combo selection state, pins one admitted active-account snapshot for the request, and calls `GetWebSearchResults`, so it starts no CLI or second model. The wire model UID comes from the catalog's family metadata (`ClientModelConfig` #23/#30/#31): a family id with no effort anchors on the family's default member and selects the nearest enabled rung, rounding up first; an effort moves only the effort axis, to the lowest rung at or above it (else the highest below) while Fast Mode, 1M Context and the other axes stay at the anchor's values unless the caller asked for `fast` or a `1m` value; not lowering a requested effort outranks keeping those axes (an unranked member counts as lower), a disabled row the caller named is kept when the request still selects it so the preflight names that refusal, and resolution never leaves the family. Rows without family metadata fall back to suffix resolution, whose variant scan matches the collapsed base rather than a string prefix. With no caller or configured output cap, the selected row's catalog `maxOutputTokens` fills CompletionConfiguration #2; #3 is `max_newlines` and is sent at a fixed value, never a context window. The leading system text is sent as `GetChatMessage` #2, a failed tool result sets ChatMessagePrompt #9 and keeps an in-band `ERROR:` marker, and Gemini uids have JSON-Schema type arrays expressed as type-only `anyOf` branches in tool parameters. Shared constraints remain on the surrounding node, so nested schemas have one wire representation while outer enum/const, boolean constraints (including `not`, `oneOf`, and `allOf`) on null, and existing null-branch restrictions retain their conjunctive meaning. A null-only type keeps its null constraint even when an outer enum/const excludes null, preserving an unsatisfiable schema without producing an empty anyOf. Schema resource identifiers, anchors, definition maps, and existing anyOf branches remain on their original node. The type constraint is appended to existing allOf entries without shifting their indices, so JSON Pointer targets and reference scopes remain unchanged. A pre-output `invalid_argument` on a history whose word-piece estimate, using the sanitized and truncated tool descriptions actually sent on the wire, reaches 95% of the selected UID's catalog input window, capped by configured provider and model limits (512 KiB of text only when no window is known) is surfaced as `context_length_exceeded` so Codex compacts; a small request with the same code stays a plain 400. Known limit: a malformed schema on a history already at or above that 95% threshold is also reported as overflow because the upstream returns the same `invalid_argument`. The `COGNITION_BLOCKLIST_REWRITES` sanitizer rewrites exact phrases Cognition refuses with `permission_denied`; it runs only on instruction surfaces — tool descriptions and the #2 system prompt — while message text, replayed thinking (#11), and tool-call arguments stay byte-exact, because a rewrite there would silently change literal content such as patches or quoted file bytes; a trigger inside a data field surfaces as the upstream `permission_denied` instead (the Codex `` escalation boilerplate is the recorded #2 case). | | `src/adapters/kiro.ts` and `src/adapters/kiro/` | Kiro event/tool/thinking/truncation/retry handling, including an egress-aware completion fallback, fixed public HTTP 5xx text, and closed-set status/code diagnostics. The original path is a facade over leaves for wire identity, reasoning, conversation state, token estimation, payload assembly, streaming, and the adapter. | | `src/adapters/mimo-free.ts` | Mimo Free transport (client identity + JWT). Concurrent requests share one JWT bootstrap bound only to its timeout; each request stops waiting on its own abort without cancelling the others. Its registry entry is `deprecated`: existing saved providers keep the adapter and their stored configuration, while new-provider discovery (presets, the init menu, the featured list, and key logins) excludes it and `provider add` warns before creating one. | -| `src/adapters/command-code.ts`, `src/adapters/command-code-tool-text.ts`, `src/adapters/command-code-restored-schema.ts` | Command Code OAuth NDJSON translation. For every `xiaomi/mimo-` model, text, native calls, reasoning, and terminal decisions share one byte-bounded queue with linear queue visits. Markup is deduplicated against matching native calls; text-only restoration requires one contiguous bare text block, a clean finish, a declared tool, and arguments validated against supported schema constraints. A parameter-free (freeform) block may omit `` but must end with ``; parameter blocks keep the canonical close. Markup appended after prose, including a marker in a later delta of the same text block, is held as a tail that can never mint a call: possible trailing marker prefixes stay in the same byte-accounted probe across deltas and release as text on a mismatch, boundary or end; a same-content native call strips a completed envelope as an echo, and anything else releases as presentation text so quoted examples stay inert. A tail waits only while a native input it could echo is open, counting the first later input of its own tool, so it is released as soon as those close without a match. Native, reasoning, and other intervening events interrupt a still-probing block but leave a held block held in arrival order, and the queued byte bound still flushes an unresolved envelope as text. An envelope the strict parser rejects but that opens with ``, closes with ``, and names a declared function is dropped when a native call for that same function arrives and on a clean finish; markup that parses but fits no supported schema is still released as text. Regex patterns, other unsupported constraints, and abnormal finishes fail closed. `tests/providers/command-code-tool-text-prose-split.test.ts` covers the prose boundary, the interleaved-event hold, and both drop paths. | +| `src/adapters/command-code.ts`, `src/adapters/command-code-tool-text.ts`, `src/adapters/command-code-restored-schema.ts` | Command Code OAuth NDJSON translation. For every `xiaomi/mimo-` model, text, native calls, reasoning, and terminal decisions share one byte-bounded queue with linear queue visits. Markup is deduplicated against matching native calls; text-only restoration requires one contiguous bare text block, a clean finish, a declared tool, and arguments validated against supported schema constraints. A parameter-free (freeform) block may omit `` but must end with ``; parameter blocks keep the canonical close. Markup appended after prose, including a marker in a later delta of the same text block, is held as a tail that can never mint a call: possible trailing marker prefixes stay in the same byte-accounted probe across deltas and release as text on a mismatch, boundary or end; a same-content native call strips a completed envelope as an echo, and anything else releases as presentation text so quoted examples stay inert. A tail waits only while a native input it could echo is open, counting the first later input of its own tool, so it is released as soon as those close without a match. Native, reasoning, and other intervening events interrupt a still-probing block but leave a held block held in arrival order, and the queued byte bound still flushes an unresolved envelope as text. An envelope the strict parser rejects but that opens with ``, closes with ``, and names a declared function is dropped when a native call for that same function arrives and on a clean finish; markup that parses but fits no supported schema is still released as text. Regex patterns, other unsupported constraints, and abnormal finishes fail closed. `tests/providers/command-code-tool-text-prose-split.test.ts` covers the prose boundary, the interleaved-event hold, and both drop paths. On OAuth rows the native fetch path normalizes only a 400 whose JSON is `success: false`, `error.code: "BAD_REQUEST"`, and message begins `You have insufficient credits to make this request.` to 429, adding `x-opencodex-refusal: insufficient-credits` and preserving the upstream status in `x-opencodex-upstream-status: 400`; ordinary 400s, other statuses, malformed or oversized bodies, and malformed UTF-8 stay untouched. Key and forward rows keep the 400, because a canonical key row has a same-key 429 retry ladder (`rateLimitRetryPolicyFor`) that would replay a credential with no credits left. A genuine upstream 429 keeps its own `Retry-After` and a 402 stays a 402 even when its body repeats the credit wording; `tests/providers/command-code-credit-failover.test.ts` pins that boundary and `tests/providers/command-code-credit-rotation.test.ts` drives the refusal through dispatch to the sibling account. | | `src/adapters/image.ts`, `src/adapters/anthropic-image-guard.ts`, `src/adapters/anthropic-image-normalize.ts`, `src/adapters/anthropic-image-codec.ts` | Image conversion for adapter ingress and Anthropic-specific normalization/limits. An image's ladder position is pinned to its own identity (content hash + media type), so appending a newer image cannot re-encode older ones and bust Anthropic's prompt prefix cache (#4532). | | `src/adapters/run-turn-queue.ts`, `src/adapters/tool-catalog-nudge.ts`, `src/adapters/identity.ts`, `src/adapters/upstream-http-error.ts` | Shared adapter execution support: turn queueing, tool-catalog nudging, client identity, upstream error normalization. | diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index dc05b2fb629..e829842b551 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -605,7 +605,8 @@ "combo-vision-enrollment.test.ts": "routing", "combo-stream-preflight.test.ts": "routing", "combo-workspace-data.test.ts": "gui", "combo-workspace-jev-decision.test.ts": "gui", "combos.test.ts": "codex-integration", "combos-kiro-profile-hop.test.ts": "codex-integration", - "command-code-error-finish.test.ts": "providers", "command-code-efforts.test.ts": "providers", + "command-code-error-finish.test.ts": "providers", "command-code-credit-failover.test.ts": "providers", "command-code-credit-rotation.test.ts": "providers", + "command-code-efforts.test.ts": "providers", "command-code-project-context.test.ts": "providers", "command-code-provider.test.ts": "providers", "command-code-quota.test.ts": "providers", "command-code-tool-text.test.ts": "providers", "command-code-workspace-cache.test.ts": "providers", "commandcode-provider.test.ts": "providers", diff --git a/tests/providers/command-code-credit-failover.test.ts b/tests/providers/command-code-credit-failover.test.ts new file mode 100644 index 00000000000..4bc405703be --- /dev/null +++ b/tests/providers/command-code-credit-failover.test.ts @@ -0,0 +1,198 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createCommandCodeAdapter } from "../../src/adapters/command-code"; +import { resetCommandCodeReasoningEffortsForTest } from "../../src/providers/command-code-efforts"; +import type { OcxParsedRequest, OcxProviderConfig } from "../../src/types"; + +const CREDITS_EXHAUSTED = { + success: false, + error: { + code: "BAD_REQUEST", + message: "You have insufficient credits to make this request. Please purchase more credits to continue using the service.", + }, +}; + +function parsed(modelId = "deepseek/deepseek-v4-flash", reasoning = "high"): OcxParsedRequest { + return { + modelId, + stream: true, + context: { + systemPrompt: [], + messages: [{ role: "user", content: "hello", timestamp: 1 }], + tools: [], + }, + options: { reasoning, maxOutputTokens: 100 }, + }; +} + +function adapterWith(fetch: typeof globalThis.fetch, authMode: OcxProviderConfig["authMode"] = "oauth") { + return createCommandCodeAdapter({ + adapter: "command-code", + baseUrl: "https://api.commandcode.ai", + authMode, + apiKey: "synthetic-credential", + fetch, + } as OcxProviderConfig & { fetch: typeof globalThis.fetch }); +} + +afterEach(() => resetCommandCodeReasoningEffortsForTest()); + +describe("Command Code insufficient-credit refusal", () => { + test("maps the exact credit refusal to the shared 429 rotation path", async () => { + let calls = 0; + const fetchMock = (async () => { + calls += 1; + return new Response(JSON.stringify(CREDITS_EXHAUSTED), { + status: 400, + headers: { "content-type": "application/json" }, + }); + }) as typeof globalThis.fetch; + const adapter = adapterWith(fetchMock); + const response = await adapter.fetchResponse(await adapter.buildRequest(parsed())); + + expect(calls).toBe(1); + expect(response.status).toBe(429); + expect(response.headers.get("x-opencodex-upstream-status")).toBe("400"); + expect(response.headers.get("x-opencodex-refusal")).toBe("insufficient-credits"); + expect(await response.json()).toEqual(CREDITS_EXHAUSTED); + }); + + test("leaves ordinary 400s and other statuses untouched", async () => { + const fixtures = [ + { status: 400, body: { success: false, error: { code: "BAD_REQUEST", message: "invalid params" } } }, + { status: 400, body: { success: false, error: { code: "RATE_LIMITED", message: CREDITS_EXHAUSTED.error.message } } }, + { status: 402, body: CREDITS_EXHAUSTED }, + { status: 400, body: { success: true, error: CREDITS_EXHAUSTED.error } }, + { status: 400, body: { success: false, error: { code: "BAD_REQUEST", message: "You have insufficient credits to make this request!" } } }, + { status: 400, body: { success: false, error: { code: "BAD_REQUEST", message: "You have insufficient credits to make this request" } } }, + { status: 400, body: { success: false, error: { code: "BAD_REQUEST", message: "you have insufficient credits to make this request." } } }, + ]; + + for (const fixture of fixtures) { + let calls = 0; + const fetchMock = (async () => { + calls += 1; + return new Response(JSON.stringify(fixture.body), { status: fixture.status }); + }) as typeof globalThis.fetch; + const adapter = adapterWith(fetchMock); + const response = await adapter.fetchResponse(await adapter.buildRequest(parsed())); + + expect(calls).toBe(1); + expect(response.status).toBe(fixture.status); + expect(response.headers.get("x-opencodex-refusal")).toBeNull(); + expect(response.headers.get("x-opencodex-upstream-status")).toBeNull(); + } + }); + + test("leaves the credit refusal as a 400 on key and forward rows", async () => { + // Only OAuth has sibling accounts to rotate to. A canonical key row has a same-key 429 retry + // ladder (rateLimitRetryPolicyFor), so a rewrite would replay a credential with no credits left. + for (const authMode of ["key", "forward"] as const) { + let calls = 0; + const adapter = adapterWith((async () => { + calls += 1; + return new Response(JSON.stringify(CREDITS_EXHAUSTED), { status: 400 }); + }) as typeof globalThis.fetch, authMode); + const response = await adapter.fetchResponse(await adapter.buildRequest(parsed())); + + expect(calls).toBe(1); + expect(response.status).toBe(400); + expect(response.headers.get("x-opencodex-refusal")).toBeNull(); + expect(response.headers.get("x-opencodex-upstream-status")).toBeNull(); + expect(await response.json()).toEqual(CREDITS_EXHAUSTED); + } + }); + + test("keeps upstream 429 and 402 refusals out of the credit classification", async () => { + // A real 429 already reaches the OAuth rotation and must keep its own Retry-After; a 402 + // is a different failure class downstream. Neither may be relabelled as the credit + // refusal, even when its body carries the same wording. + const rateLimited = { error: { type: "rate_limit_error", message: "Too many requests" } }; + const paymentRequired = { error: { code: "PAYMENT_REQUIRED", message: "Payment required" } }; + const fixtures = [ + { status: 429, body: rateLimited, headers: { "retry-after": "30" } }, + { status: 429, body: CREDITS_EXHAUSTED, headers: { "retry-after": "7" } }, + { status: 402, body: CREDITS_EXHAUSTED, headers: {} }, + { status: 402, body: paymentRequired, headers: {} }, + ]; + + for (const fixture of fixtures) { + let calls = 0; + const fetchMock = (async () => { + calls += 1; + return new Response(JSON.stringify(fixture.body), { + status: fixture.status, + headers: { "content-type": "application/json", ...fixture.headers }, + }); + }) as typeof globalThis.fetch; + const adapter = adapterWith(fetchMock); + const response = await adapter.fetchResponse(await adapter.buildRequest(parsed())); + + expect(calls).toBe(1); + expect(response.status).toBe(fixture.status); + expect(response.headers.get("x-opencodex-refusal")).toBeNull(); + expect(response.headers.get("x-opencodex-upstream-status")).toBeNull(); + expect(response.headers.get("retry-after")).toBe(fixture.headers["retry-after" as keyof typeof fixture.headers] ?? null); + expect(await response.json()).toEqual(fixture.body); + } + }); + + test("fails open for malformed and oversized bodies", async () => { + const invalidUtf8Prefix = new TextEncoder().encode( + '{"success":false,"error":{"code":"BAD_REQUEST","message":"You have insufficient credits to make this request.', + ); + const invalidUtf8Suffix = new TextEncoder().encode('"}}'); + const invalidUtf8Body = new Uint8Array(invalidUtf8Prefix.length + 1 + invalidUtf8Suffix.length); + invalidUtf8Body.set(invalidUtf8Prefix); + invalidUtf8Body[invalidUtf8Prefix.length] = 0x80; + invalidUtf8Body.set(invalidUtf8Suffix, invalidUtf8Prefix.length + 1); + const fixtures = [ + new Response("{not json", { status: 400 }), + new Response(JSON.stringify({ ...CREDITS_EXHAUSTED, pad: "x".repeat(9_000) }), { status: 400 }), + new Response(invalidUtf8Body, { status: 400 }), + ]; + + for (const fixture of fixtures) { + let calls = 0; + const fetchMock = (async () => { + calls += 1; + return fixture.clone(); + }) as typeof globalThis.fetch; + const adapter = adapterWith(fetchMock); + const response = await adapter.fetchResponse(await adapter.buildRequest(parsed())); + + expect(calls).toBe(1); + expect(response.status).toBe(400); + expect(response.headers.get("x-opencodex-refusal")).toBeNull(); + } + }); + + test("normalizes the retry response after a reasoning-effort rejection", async () => { + let calls = 0; + const fetchMock = (async () => { + calls += 1; + if (calls === 1) { + return new Response(JSON.stringify({ error: "unsupported reasoning_effort" }), { status: 400 }); + } + return new Response(JSON.stringify(CREDITS_EXHAUSTED), { status: 400 }); + }) as typeof globalThis.fetch; + const adapter = adapterWith(fetchMock); + const response = await adapter.fetchResponse(await adapter.buildRequest(parsed("moonshotai/Kimi-K3", "max"))); + + expect(calls).toBe(2); + expect(response.status).toBe(429); + expect(response.headers.get("x-opencodex-refusal")).toBe("insufficient-credits"); + expect(response.headers.get("x-opencodex-upstream-status")).toBe("400"); + expect(await response.json()).toEqual(CREDITS_EXHAUSTED); + }); + + test("re-throws the caller abort reason instead of failing open", async () => { + const controller = new AbortController(); + const reason = new Error("caller cancelled"); + const adapter = adapterWith((async () => { + controller.abort(reason); + return new Response(JSON.stringify(CREDITS_EXHAUSTED), { status: 400 }); + }) as typeof globalThis.fetch); + await expect(adapter.fetchResponse(await adapter.buildRequest(parsed()), { abortSignal: controller.signal })) + .rejects.toBe(reason); + }); +}); diff --git a/tests/providers/command-code-credit-rotation.test.ts b/tests/providers/command-code-credit-rotation.test.ts new file mode 100644 index 00000000000..681a2182884 --- /dev/null +++ b/tests/providers/command-code-credit-rotation.test.ts @@ -0,0 +1,90 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { clearGenericFailoverHealth } from "../../src/oauth/generic-account-failover"; +import { getAccountSet, saveCredential, setActiveAccount } from "../../src/oauth/store"; +import { resetCommandCodeReasoningEffortsForTest } from "../../src/providers/command-code-efforts"; +import { handleResponses } from "../../src/server/responses"; +import type { OcxConfig } from "../../src/types"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const GENERATE_URL = "https://api.commandcode.ai/alpha/generate"; +const CREDIT_MESSAGE = "You have insufficient credits to make this request. Please purchase more credits to continue using the service."; +const COMPLETED = [ + JSON.stringify({ type: "text-delta", text: "OK" }), + JSON.stringify({ type: "finish", finishReason: "stop" }), +].join("\n") + "\n"; + +const originalHome = process.env.OPENCODEX_HOME; +const originalFetch = globalThis.fetch; +let fixtureHome: string; +let releaseSpend: (() => void) | undefined; + +beforeEach(async () => { + fixtureHome = mkdtempSync(join(tmpdir(), "ocx-command-credit-rotation-")); + process.env.OPENCODEX_HOME = fixtureHome; + releaseSpend = acquireOwnedSpendHome(); + clearGenericFailoverHealth(); + for (let index = 0; index < 2; index++) await saveCredential("command-code", { + access: `synthetic-command-${index}`, refresh: `synthetic-refresh-${index}`, + expires: Date.now() + 3_600_000, accountId: `fixture-${index}`, source: "oauth", + }, { addAccount: true }); + await setActiveAccount("command-code", getAccountSet("command-code")!.accounts[0]!.id); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + releaseSpend?.(); + releaseSpend = undefined; + clearGenericFailoverHealth(); + resetCommandCodeReasoningEffortsForTest(); + if (originalHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = originalHome; + removeTreeWithRetry(fixtureHome); +}); + +/** The first account answers with `refusal`; any other account completes the turn. */ +async function sendWithFirstAccountRefusal(refusal: () => Response) { + const authorizations: string[] = []; + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url !== GENERATE_URL) throw new Error(`Unexpected fixture request: ${url}`); + const authorization = new Headers(init?.headers).get("authorization") ?? ""; + authorizations.push(authorization); + return authorization === "Bearer synthetic-command-0" ? refusal() : new Response(COMPLETED); + }) as typeof fetch; + const config = { defaultProvider: "command-code", providers: { + "command-code": { adapter: "command-code", baseUrl: "https://api.commandcode.ai", authMode: "oauth", + models: ["deepseek/deepseek-v4-flash"] }, + } } as OcxConfig; + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "command-code/deepseek/deepseek-v4-flash", input: "hello", stream: false }), + }), config, { model: "", provider: "" }); + return { response, body: await response.text(), authorizations }; +} + +describe("Command Code credit refusal through the dispatch path", () => { + test("an exhausted account's credit refusal rotates the same request to its sibling", async () => { + const { response, body, authorizations } = await sendWithFirstAccountRefusal(() => Response.json( + { success: false, error: { code: "BAD_REQUEST", message: CREDIT_MESSAGE } }, { status: 400 }, + )); + + expect(response.status, body).toBe(200); + expect(JSON.parse(body).status).toBe("completed"); + expect(authorizations).toEqual(["Bearer synthetic-command-0", "Bearer synthetic-command-1"]); + expect(getAccountSet("command-code")!.activeAccountId).toBe(getAccountSet("command-code")!.accounts[1]!.id); + }, 20_000); + + test("an ordinary BAD_REQUEST stays on the account and is never treated as exhaustion", async () => { + const { response, body, authorizations } = await sendWithFirstAccountRefusal(() => Response.json( + { success: false, error: { code: "BAD_REQUEST", message: "invalid params" } }, { status: 400 }, + )); + + expect(response.status, body).toBe(400); + expect(authorizations).toEqual(["Bearer synthetic-command-0"]); + expect(getAccountSet("command-code")!.activeAccountId).toBe(getAccountSet("command-code")!.accounts[0]!.id); + }, 20_000); +}); From a564487c2eb2b72362f01f0cbf801278d2010711 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 24 Oct 2026 21:42:51 +0900 Subject: [PATCH 015/101] fix(codex): calibrate Business Premium display capacity (#7557) * fix(codex): calibrate Business Premium display capacity Business Premium seats report the plan string self_serve_business_prolite, which missed the configured capacity map and counted at the baseline weight 1. OpenAI documents Premium as 5x Standard included usage, so calibrate it at 5 in the display-only pool capacity estimate. Refs #7440 Carries #7444 Co-authored-by: Ingwannu * docs(codex): move capacity calibration rule to openai-tiers and correct unknown-plan wording --------- Co-authored-by: Ingwannu --- docs-site/src/content/docs/guides/providers.md | 2 ++ src/providers/codex-capacity.ts | 4 +++- structure/INDEX.md | 1 + structure/manifest.json | 3 ++- structure/providers/openai-tiers.md | 4 ++++ tests/providers/provider-capacity.test.ts | 15 ++++++++++++++- 6 files changed, 26 insertions(+), 3 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index a8d5144e8b9..f1b99c02e79 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -52,6 +52,8 @@ reset is expected to recover as `+N% pool capacity`. **Incomplete coverage** mea accounts could not safely contribute to the estimate, for example because their plan or quota is unknown, their reading is stale, or the account is paused or needs reauthentication. +Business Premium seats reported as `self_serve_business_prolite` count as five Standard-seat units in this estimate. This display weight does not select accounts or create a five-hour window when the upstream reports only weekly usage. The calibration follows the [official Business usage limits](https://help.openai.com/en/articles/12003714-chatgpt-business-models-and-limits), checked 2026-10-20; unknown plans continue to count at the baseline with the unknown-plan count disclosed. + A **partial window coverage** warning means some included accounts reported one quota window but not another. The overview keeps those windows separate and marks each affected window incomplete instead of treating the missing reading as usage for that window. diff --git a/src/providers/codex-capacity.ts b/src/providers/codex-capacity.ts index 604176f8e1d..7b08da220c2 100644 --- a/src/providers/codex-capacity.ts +++ b/src/providers/codex-capacity.ts @@ -5,6 +5,8 @@ export const CODEX_CONFIGURED_CAPACITY_WEIGHTS = { team: 1, business: 1, prolite: 5, + // Business Premium has 5x Standard included usage; this is display calibration only (#7440). + self_serve_business_prolite: 5, pro: 20, } as const; @@ -13,7 +15,7 @@ export const CODEX_CONFIGURED_CAPACITY_WEIGHTS = { * * `CodexAccount.plan` is an unrestricted upstream string and cannot be enumerated: the * bundled model snapshot alone carries 21 distinct plan names (`edu_plus`, `finserv`, - * `k12`, `quorum`, `self_serve_business_usage_based`, …) against the five listed above. + * `k12`, `quorum`, `self_serve_business_usage_based`, …) against the calibrated entries above. * Treating an unlisted plan as unknown dropped the account from the estimate entirely and * reported "incomplete coverage" — which is how a Business seat upgraded to Premium * disappeared from its own capacity report (#3155), and it was already happening to the diff --git a/structure/INDEX.md b/structure/INDEX.md index e4982e65274..f33039a1cfd 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -259,6 +259,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/providers/anthropic-billing-refusal.ts` | [`providers/anthropic-key-billing.md`](providers/anthropic-key-billing.md) | | `src/providers/antigravity-quota-identity.ts` | [`providers/jev-decision.md`](providers/jev-decision.md) | | `src/providers/api-key-pause.ts` | [`account-auto-pause.md`](account-auto-pause.md) | +| `src/providers/codex-capacity.ts` | [`providers/openai-tiers.md`](providers/openai-tiers.md) | | `src/providers/dsh-account-destination.ts` | [`providers/dsh-account.md`](providers/dsh-account.md) | | `src/providers/quota-decision-antigravity.ts` | [`providers/jev-decision.md`](providers/jev-decision.md) | | `src/providers/quota-decision-publication.ts` | [`providers/jev-decision.md`](providers/jev-decision.md) | diff --git a/structure/manifest.json b/structure/manifest.json index 3f6079e5566..7b0d19d4260 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -578,7 +578,8 @@ "title": "OpenAI Provider Account Modes", "scope": "Pool/Direct account modes, API-key separation, and the public provider and quota contract.", "documents": [ - "src/codex/" + "src/codex/", + "src/providers/codex-capacity.ts" ] }, { diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index caffc0dd5bf..983b001f860 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -366,6 +366,10 @@ Regression coverage lives in `tests/codex-integration/codex-quota-parser-parity. `tests/codex-integration/main-account-hard-lock-policy.test.ts`, `tests/usage/quota-reset-observation.test.ts`, and `tests/usage/quota-reset-seen-store.test.ts`. +Codex pool display capacity in `src/providers/codex-capacity.ts` calibrates the observed Business Premium plan +`self_serve_business_prolite` at five Standard-seat units. This estimate does not drive routing or create a +missing five-hour window; unknown plans keep the conservative baseline and are counted in `unknownPlanAccounts`. + `codexMainAccountHardLock` is a local admission policy that is **on by default** since #5694, at short 90% and long `MAIN_ACCOUNT_HARD_LOCK_PERCENT` = 98%, overridden by ordered integer `codexMainAccountHardLockThresholds` in 80..100. The 5h/short and weekly windows each govern on diff --git a/tests/providers/provider-capacity.test.ts b/tests/providers/provider-capacity.test.ts index 5c3f9bf9182..1a8c28443bc 100644 --- a/tests/providers/provider-capacity.test.ts +++ b/tests/providers/provider-capacity.test.ts @@ -44,6 +44,19 @@ describe("configured-weight Codex pool capacity", () => { expect(aggregateCodexPoolCapacity(rows(9), NOW).quota?.weeklyPercent).toBeCloseTo(39.33333333, 8); }); + test("Business Premium seats contribute five weekly units without inventing a burst window (#7440)", () => { + const result = aggregateCodexPoolCapacity([ + account("self_serve_business_prolite", 100), + account(" SELF_SERVE_BUSINESS_PROLITE ", 0), + account("team", 100), + ], NOW); + expect(result.aggregation).toMatchObject({ includedAccounts: 3, unknownPlanAccounts: 0 }); + expect(result.aggregation?.weekly).toMatchObject({ totalWeight: 11, consumedWeight: 6, remainingWeight: 5 }); + expect(result.quota?.weeklyPercent).toBeCloseTo(600 / 11, 8); + expect(result.quota?.fiveHourPercent).toBeUndefined(); + expect(result.aggregation?.fiveHour).toBeUndefined(); + }); + test("legacy Team and Business plans share configured weight", () => { const result = aggregateCodexPoolCapacity([ account("team", 20, { isMain: true, active: true }), @@ -100,7 +113,7 @@ describe("configured-weight Codex pool capacity", () => { account("business", 30, { needsReauth: true }), ], NOW); // #3155: an unrecognized plan string no longer drops the account. The upstream plan - // field is unrestricted - the bundled snapshot alone carries 21 names against the five + // field is unrestricted - the bundled snapshot alone carries 21 names against the small set // calibrated here - so "future-plan" is weighted at the baseline seat instead of being // silently omitted from a coverage figure the operator is reading as complete. expect(result.aggregation).toMatchObject({ From 3decf65ed6b9a93f9dc5fb483faa7005d83ad31a Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sat, 24 Oct 2026 12:43:23 +0000 Subject: [PATCH 016/101] fix(fallback): check the first hard-lock profile destination (#7589) Apply fallback policy to the ranked profile selection before authentication or dispatch. Keep hard-lock pre-admission unchanged and cover refused and allowed selections with a synthetic caller-owned credential and strict fetch stub. Co-authored-by: JUN --- .../src/content/docs/guides/fallback-off.md | 4 + scripts/test-layout/layout.json | 2 +- src/server/responses/request-prepare.ts | 19 +++- structure/fallback-policy.md | 9 +- structure/transports/policy-fallback.md | 4 + ...account-hard-lock-profile-dispatch.test.ts | 107 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 7 files changed, 143 insertions(+), 3 deletions(-) create mode 100644 tests/codex-integration/main-account-hard-lock-profile-dispatch.test.ts diff --git a/docs-site/src/content/docs/guides/fallback-off.md b/docs-site/src/content/docs/guides/fallback-off.md index 32bb6c5faf4..a48697db2f3 100644 --- a/docs-site/src/content/docs/guides/fallback-off.md +++ b/docs-site/src/content/docs/guides/fallback-off.md @@ -24,6 +24,10 @@ Rotation among the accounts or API keys of **one provider pool** for the same mo It is how a pool absorbs a rate-limited account, and it keeps working whatever you set here. Retrying the same target, and transport fallbacks that keep the same target, are not fallbacks either. +When a main-account hard lock routes to a profile, its first selected destination also follows these +switches. Allowing provider fallback does not allow a different model on the same provider when model +fallback is off, even if that profile also contains another provider. + ## Turn fallback off ```bash diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 647d679cb2b..2fdded5a374 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -142,7 +142,7 @@ "tool-envelope-echo-whole-line.test.ts": "adapters", "abort-race.test.ts": "adapters", "account-import.test.ts": "server", - "account-import-project-fallback.test.ts": "server", + "account-import-project-fallback.test.ts": "server", "main-account-hard-lock-profile-dispatch.test.ts": "codex-integration", "account-pool-management-api.test.ts": "server", "link-listener-admission.test.ts": "server", "link-management-relay.test.ts": "server", "quota-pool-relay.test.ts": "server", "quota-pool.test.ts": "usage", "fallback-policy.test.ts": "routing", "responses-combo-fallback-off.test.ts": "responses", "main-account-hard-lock-fallback-off.test.ts": "codex-integration", "subagent-model-fallback-off.test.ts": "routing", "routing-policy-fallback-off.test.ts": "routing", "fallback-routes.test.ts": "server", "client-home-unreachable.test.ts": "clients", "cli-fallback.test.ts": "cli", "link-listener-lifecycle.test.ts": "server", "link-listener-mutation.test.ts": "server", diff --git a/src/server/responses/request-prepare.ts b/src/server/responses/request-prepare.ts index 9830fef7e09..3325529564c 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -163,7 +163,7 @@ import { import { acquireUpstreamHostAdmission } from "../../codex/upstream-host-health"; import { applyCompactionRoutingOverride, compactionRoutingKeepsProviderIdentity } from "./compaction-routing"; import { applyMainHardLockFallbackOverride } from "./main-hard-lock-routing"; -import { FallbackRefusedError } from "../../routing/fallback-policy"; +import { FallbackRefusedError, guardFallbackHop } from "../../routing/fallback-policy"; import { codexAuthContextLogLabel } from "../../codex/account-label"; import { conversationStateBindingFromAuth, @@ -778,6 +778,23 @@ export async function prepareResponsesRequest( if (parsed._compactionRequest === true || options.compactionRoutingOverride) parsed._cursorIsolateConversation = true; route = memoryRoute ?? shadowRoute ?? resolveRoute(options.policyFallbackCandidate ? `${options.policyFallbackCandidate.provider}/${options.policyFallbackCandidate.model}` : parsed.modelId); + // The hard-lock rewrite checked a declared profile representative. Enforce the actual + // ranked destination against the locked source before admitting this first selection. + const hardLockProfileTrace = options.mainHardLockFallbackOverride ? route.routeDecision?.profile : undefined; + if (hardLockProfileTrace && options.mainHardLockFallbackOverride) { + const override = options.mainHardLockFallbackOverride; + const hop = guardFallbackHop(config, { + site: "routing-profile", + route: override.sourceModel, + aliases: [`policy/${hardLockProfileTrace.id}`], + from: { provider: OPENAI_CODEX_PROVIDER_ID, model: override.sourceModel }, + to: { provider: route.providerName, model: route.modelId }, + trigger: "hard_lock", + admission: options.admission, + }); + if (!hop.ok) return hop.response; + hop.commit(); + } leaveDirectLaneIfRerouted(options, route, parsed, logCtx); // Name the phase in the persisted route decision, so the request log says why this turn went to // the memory destination instead of leaving it looking like a plain user selection. Set here, on diff --git a/structure/fallback-policy.md b/structure/fallback-policy.md index 2e7d1df11ec..a0d804ce399 100644 --- a/structure/fallback-policy.md +++ b/structure/fallback-policy.md @@ -50,7 +50,7 @@ and is cached for five seconds. Loopback and unkeyed requests have no device sco | Main-account hard-lock fallback, `src/server/responses/main-hard-lock-routing.ts` | before the body is rewritten; `FallbackRefusedError` carries the refusal to `request-prepare.ts` | — | `hard_lock`, 429 | A refused hop never contacts its refused destination. Hard-lock pre-admission refuses before -rewriting the body; its first-dispatch check can refuse after that rewrite. +rewriting the body; its first combo dispatch or profile selection can refuse after that rewrite. The combo and routing-profile sites classify a hop by resolved destinations: both ends pass through `routeConcreteModel`, so provider aliases and `blockedModelRedirects` are applied, and the end a hop @@ -70,6 +70,13 @@ the locked OpenAI source, with `trigger: hard_lock`. A prior provider-hop admiss a same-provider model hop when model fallback is off. The combo records an allowed first hop after its send-budget reservation. +For a hard-lock profile rewrite, `src/server/responses/request-prepare.ts` checks the actual first +ranked destination against the original OpenAI model with `trigger: hard_lock`. A declared provider +hop does not admit a same-provider model hop when model fallback is off. The guard records an allowed +selection when it is accepted; later authentication or preparation can still refuse it. +`tests/codex-integration/main-account-hard-lock-profile-dispatch.test.ts` covers refusal and allowed +dispatch with a synthetic caller-owned credential and a fetch stub. + ## Refusal contract `fallbackRefusedResponse`: `error.type` and `error.code` `fallback_refused`, `error.fallback` with diff --git a/structure/transports/policy-fallback.md b/structure/transports/policy-fallback.md index 89b8069220a..12bb2adffdb 100644 --- a/structure/transports/policy-fallback.md +++ b/structure/transports/policy-fallback.md @@ -10,6 +10,10 @@ accepted, so a declined policy target never scopes the request's own route. so a public combo or profile alias cannot reselect its destination. Redirects remain inside the original evaluation, including eligible destinations omitted from the bounded diagnostic trace. +When a main-account hard-lock fallback selects a profile, `src/server/responses/request-prepare.ts` +checks its first ranked destination against the original model under the +[fallback policy](../fallback-policy.md#sites), before authentication or upstream dispatch. + Normal virtual-model wire mapping remains attached to its admitted selector and does not add a new eligible model. Policy traversal records settled physical destinations and skips a destination already attempted through another candidate or redirect; same-target credential and transient retries retain diff --git a/tests/codex-integration/main-account-hard-lock-profile-dispatch.test.ts b/tests/codex-integration/main-account-hard-lock-profile-dispatch.test.ts new file mode 100644 index 00000000000..e85670eebeb --- /dev/null +++ b/tests/codex-integration/main-account-hard-lock-profile-dispatch.test.ts @@ -0,0 +1,107 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { existsSync } from "node:fs"; +import { handleResponses } from "../../src/server/responses"; +import { captureMainQuotaWriter, clearMainAccountInfoCache, observeMainQuotaIdentity } from "../../src/codex/main-account-cache"; +import { getMainAccountHardLockStatus } from "../../src/codex/main-account-hard-lock"; +import { clearAccountQuota, setAccountQuotaFromParsed } from "../../src/codex/quota"; +import { clearFallbackEventsForTests, recentFallbackEvents } from "../../src/routing/fallback-policy"; +import { closeRequestHistoryIndex } from "../../src/routing/history/indexer"; +import type { OcxConfig } from "../../src/types"; +import { fakeChatGptJwt } from "../helpers/fake-chatgpt-jwt"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; + +const originalFetch = globalThis.fetch; +const callerId = "profile-dispatch-caller"; +const callerToken = fakeChatGptJwt({ chatgpt_account_id: callerId, exp: 4102444800 }); +let home: TempHome; +let releaseSpendHome: (() => void) | undefined; +let sent: Array<{ model: unknown; callerCredential: boolean; callerAccount: boolean }>; + +beforeEach(() => { + // Every network request terminates here; unexpected URLs fail instead of reaching a provider. + sent = []; + globalThis.fetch = (async (input, init) => { + const request = input instanceof Request ? input : new Request(String(input), init); + const url = new URL(request.url); + if (url.hostname !== "chatgpt.com" || !url.pathname.endsWith("/responses")) { + throw new Error(`Unexpected fixture destination: ${url.origin}${url.pathname}`); + } + const headers = new Headers(init?.headers ?? request.headers); + const body = JSON.parse(typeof init?.body === "string" ? init.body : await request.text()); + sent.push({ model: body.model, + callerCredential: headers.get("authorization") === `Bearer ${callerToken}`, + callerAccount: headers.get("chatgpt-account-id") === callerId }); + return Response.json({ id: "resp_profile_fixture", status: "completed", + output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: "ok" }] }], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 } }); + }) as typeof fetch; + home = createTempHome("ocx-main-lock-profile-dispatch-"); + releaseSpendHome = acquireOwnedSpendHome(); + clearAccountQuota(); + clearMainAccountInfoCache(); + clearFallbackEventsForTests(); + observeMainQuotaIdentity("profile-dispatch-locked-main"); + const writer = captureMainQuotaWriter("profile-dispatch-locked-main"); + if (!writer) throw new Error("Fixture main identity was not observed"); + setAccountQuotaFromParsed("__main__", { weeklyPercent: 98 }, undefined, writer); +}); + +afterEach(() => { + releaseSpendHome?.(); + releaseSpendHome = undefined; + closeRequestHistoryIndex(); + clearAccountQuota(); + clearMainAccountInfoCache(); + clearFallbackEventsForTests(); + globalThis.fetch = originalFetch; + home.remove(); +}); + +function config(modelFallback: boolean): OcxConfig { + return { + providers: { + openai: { adapter: "openai-responses", authMode: "forward", codexAccountMode: "pool", baseUrl: "https://chatgpt.com/backend-api/codex" }, + deepseek: { adapter: "openai-responses", authMode: "key", apiKey: "fixture-unused-key" }, + }, + codexAccounts: [], + codexMainAccountHardLockFallback: { model: "policy/rescue" }, + fallback: { provider: true, model: modelFallback }, + routingProfiles: { rescue: { candidates: [ + { provider: "openai", model: "gpt-5.4" }, + { provider: "deepseek", model: "deepseek-chat" }, + ] } }, + } as OcxConfig; +} + +async function dispatch(modelFallback: boolean): Promise { + const cfg = config(modelFallback); + expect(getMainAccountHardLockStatus(cfg).state).toBe("blocked"); + expect(existsSync(home.path(".codex", "auth.json"))).toBe(false); + // An unrelated caller's own credential can serve the ranked model. This prevents the + // independent stored-main lock from masking a missing first-selection policy check. + return handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${callerToken}`, "chatgpt-account-id": callerId }, + body: JSON.stringify({ model: "gpt-5.3", stream: false, input: "hello" }), + }), cfg, { model: "", provider: "" }); +} + +test("model fallback off refuses the first ranked profile model before dispatch", async () => { + const response = await dispatch(false); + expect(response.status).toBe(429); + expect(response.headers.get("x-opencodex-fallback")).toBe("refused"); + expect(sent).toEqual([]); + expect(recentFallbackEvents().at(-1)).toMatchObject({ + site: "routing-profile", outcome: "refused", kind: "model", trigger: "hard_lock", + from: "openai/gpt-5.3", to: "openai/gpt-5.4", + }); +}); + +test("model fallback on lets the first ranked profile model use the caller's credential", async () => { + const response = await dispatch(true); + expect(response.status).toBe(200); + expect(response.headers.get("x-opencodex-fallback")).toBeNull(); + expect(sent).toEqual([{ model: "gpt-5.4", callerCredential: true, callerAccount: true }]); + expect(existsSync(home.path(".codex", "auth.json"))).toBe(false); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index e829842b551..c58da960401 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -207,6 +207,7 @@ "abort-idle-deadline.test.ts": "lib", "tool-envelope-echo-whole-line.test.ts": "adapters", "abort-race.test.ts": "adapters", "account-import.test.ts": "server", "account-import-project-fallback.test.ts": "server", + "main-account-hard-lock-profile-dispatch.test.ts": "codex-integration", "account-pool-management-api.test.ts": "server", "link-listener-admission.test.ts": "server", "link-management-relay.test.ts": "server", "quota-pool-relay.test.ts": "server", "quota-pool.test.ts": "usage", "fallback-policy.test.ts": "routing", "responses-combo-fallback-off.test.ts": "responses", "main-account-hard-lock-fallback-off.test.ts": "codex-integration", "subagent-model-fallback-off.test.ts": "routing", "routing-policy-fallback-off.test.ts": "routing", "fallback-routes.test.ts": "server", "client-home-unreachable.test.ts": "clients", "cli-fallback.test.ts": "cli", "link-listener-lifecycle.test.ts": "server", From af8cd35a0665eace70abe9cc83f4263222bcdf79 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sat, 24 Oct 2026 12:44:10 +0000 Subject: [PATCH 017/101] fix(service): bind ownership lease release to its acquisition (#7543) * fix(service): bind ownership lease release to its acquisition * docs(runtime): distinguish takeover decline from stop or claim failure Align the runtime ownership contract with the desktop shell: declined consent attaches as a guest, while failed stop or claim does not continue as owner and a stopped runtime is reported as stopped and unclaimed. Co-authored-by: Epinephrine --------- Co-authored-by: JUN --- src/service/ownership-mutation-lease.mjs | 28 +++++++++--- structure/runtime.md | 2 +- ...nership-mutation-lease-diagnostics.test.ts | 45 +++++++++++++++++++ 3 files changed, 67 insertions(+), 8 deletions(-) diff --git a/src/service/ownership-mutation-lease.mjs b/src/service/ownership-mutation-lease.mjs index 9b95bd5aa7f..a01d01d0538 100644 --- a/src/service/ownership-mutation-lease.mjs +++ b/src/service/ownership-mutation-lease.mjs @@ -174,6 +174,18 @@ export function ownershipMutationLeaseStatusLine(statePaths, options = {}) { + `opencodex waits for it to start or change service state, and ${RECLAIM_HINT}.`; } +function createLeaseHandle(path, generation, token, options) { + let released = false; + return { + token, + release: () => { + if (released) return; + released = true; + release(path, generation, options); + }, + }; +} + function reclaim(path, now, alive) { const observed = readOwner(path); const incomplete = observed ? null : readIncompleteOwner(path); @@ -201,7 +213,7 @@ export function acquireOwnershipMutationLease( const nested = held.get(path); if (nested) { nested.depth += 1; - return { token: nested.snapshot.record.token, release: () => release(path, options) }; + return createLeaseHandle(path, nested.generation, nested.snapshot.record.token, options); } const now = options.now ?? Date.now; const alive = options.processAlive ?? processAlive; @@ -213,8 +225,9 @@ export function acquireOwnershipMutationLease( if (owner?.record.token === joinToken && alive(owner.record.pid)) { delegatedTokens.set(path, joinToken); if (envJoinToken === joinToken) delete process.env[OWNERSHIP_MUTATION_LEASE_TOKEN_ENV]; - held.set(path, { depth: 1, snapshot: owner, delegated: true }); - return { token: joinToken, release: () => release(path, options) }; + const generation = {}; + held.set(path, { depth: 1, snapshot: owner, delegated: true, generation }); + return createLeaseHandle(path, generation, joinToken, options); } delegatedTokens.delete(path); if (envJoinToken === joinToken) delete process.env[OWNERSHIP_MUTATION_LEASE_TOKEN_ENV]; @@ -238,8 +251,9 @@ export function acquireOwnershipMutationLease( descriptor = null; const snapshot = readOwner(path); if (!snapshot || snapshot.record.token !== record.token) throw new Error("ownership mutation lease could not be verified"); - held.set(path, { depth: 1, snapshot, delegated: false }); - return { token: record.token, release: () => release(path, options) }; + const generation = {}; + held.set(path, { depth: 1, snapshot, delegated: false, generation }); + return createLeaseHandle(path, generation, record.token, options); } catch (error) { if (descriptor !== null) { try { closeSync(descriptor); } catch { /* stale recovery owns uncertain cleanup */ } } if (madeDirectory) { @@ -261,9 +275,9 @@ export function acquireOwnershipMutationLease( } } -function release(path, options) { +function release(path, generation, options) { const currentHeld = held.get(path); - if (!currentHeld) return; + if (!currentHeld || currentHeld.generation !== generation) return; currentHeld.depth -= 1; if (currentHeld.depth > 0) return; held.delete(path); diff --git a/structure/runtime.md b/structure/runtime.md index 43e071d457f..e6e7664cb19 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -572,7 +572,7 @@ Missing, old, malformed or unknown manager evidence blocks takeover and leaves r and autostart untouched. The supported verdict carries an opaque token over the approved subject and both manager identities; `recordServiceOwner` re-observes and compares it inside the lock, so a mutable shim or downgrade cannot inherit earlier consent. An upgrade is a -separate user-authorized action; declining or failing it leaves the app a guest. `ocx service claim` in `src/service/claim.ts` accepts the expected subject and compatibility token and calls `recordServiceOwner` under the ownership mutation lease. The CLI rechecks both before writing; a mismatch writes nothing. The desktop owns the consent prompt and treats `approval-changed`, `manager-still-active`, unreadable stop output and stop-child timeout as terminal before its silence wait or claim. Only a parsed `stopped` result or a validated exit-79 `history-incomplete` result reaches that wait. A stopped but unclaimed runtime is reported as such, without a restoration claim. +separate user-authorized action. Declining consent attaches the app as a guest. If stopping or claiming fails, the app does not proceed as the owner; a stopped runtime remains reported as stopped and unclaimed. `ocx service claim` in `src/service/claim.ts` accepts the expected subject and compatibility token and calls `recordServiceOwner` under the ownership mutation lease. `src/service/ownership-mutation-lease.mjs` returns one-shot release handles bound to an acquisition generation. Nested handles share that generation's reference count; repeated or stale handles cannot shorten a successor lease. The CLI rechecks both before writing; a mismatch writes nothing. The desktop owns the consent prompt and treats `approval-changed`, `manager-still-active`, unreadable stop output and stop-child timeout as terminal before its silence wait or claim. Only a parsed `stopped` result or a validated exit-79 `history-incomplete` result reaches that wait. A stopped but unclaimed runtime is reported as such, without a restoration claim. Activating service verbs retain their foreign/unknown durable-owner refusals. `src/service/desktop-command-guard.ts` separately refuses install, repair, start and restart diff --git a/tests/service/ownership-mutation-lease-diagnostics.test.ts b/tests/service/ownership-mutation-lease-diagnostics.test.ts index 29091e73576..3fe8b610764 100644 --- a/tests/service/ownership-mutation-lease-diagnostics.test.ts +++ b/tests/service/ownership-mutation-lease-diagnostics.test.ts @@ -125,3 +125,48 @@ describe("ownership mutation lease diagnostic lookup (carry #6655)", () => { } finally { spawned.mockRestore(); } }); }); + +describe("ownership mutation lease release handles", () => { + test("release is one-shot, retains nested holders, and cannot release a successor", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-lease-release-generation-")); + const paths = [join(root, "service-state.json")]; + const lock = `${paths[0]}.mutation.lock`; + const first = acquireOwnershipMutationLease(paths, { waitMs: 0 }); + const nested = acquireOwnershipMutationLease(paths, { waitMs: 0 }); + let successor: ReturnType | undefined; + try { + expect(nested.token).toBe(first.token); + + const originalOwner = readdirSync(lock); + expect(originalOwner).toHaveLength(1); + first.release(); + first.release(); + expect(readdirSync(lock)).toEqual(originalOwner); + expect(JSON.parse(readFileSync(join(lock, originalOwner[0]!), "utf8")).token).toBe(first.token); + + nested.release(); + expect(existsSync(lock)).toBe(false); + + successor = acquireOwnershipMutationLease(paths, { waitMs: 0 }); + expect(successor.token).not.toBe(first.token); + const successorOwner = readdirSync(lock); + expect(successorOwner).toHaveLength(1); + const successorPath = join(lock, successorOwner[0]!); + const successorBytes = readFileSync(successorPath); + expect(JSON.parse(successorBytes.toString("utf8")).token).toBe(successor.token); + + first.release(); + expect(existsSync(lock)).toBe(true); + expect(readdirSync(lock)).toEqual(successorOwner); + expect(readFileSync(successorPath)).toEqual(successorBytes); + + successor.release(); + expect(existsSync(lock)).toBe(false); + } finally { + successor?.release(); + nested.release(); + first.release(); + rmSync(root, { recursive: true, force: true }); + } + }); +}); From c5ba1e105f3bd7aa517a62a646384d90a8e33b8e Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 24 Oct 2026 21:44:45 +0900 Subject: [PATCH 018/101] test(server): isolate agent-task-recovery fixtures (#7613) (#7649) --- tests/helpers/agent-task-recovery-fixture.ts | 75 ++++++++ tests/server/agent-task-recovery.test.ts | 178 +++++++++++++++---- 2 files changed, 216 insertions(+), 37 deletions(-) create mode 100644 tests/helpers/agent-task-recovery-fixture.ts diff --git a/tests/helpers/agent-task-recovery-fixture.ts b/tests/helpers/agent-task-recovery-fixture.ts new file mode 100644 index 00000000000..7bca166fae8 --- /dev/null +++ b/tests/helpers/agent-task-recovery-fixture.ts @@ -0,0 +1,75 @@ +import { spyOn } from "bun:test"; +import * as cache from "../../src/server/responses/agent-task-recovery-cache"; +import { codexHeaders, originalFetch } from "./agent-task-recovery"; + +const resolveRecovery = cache.resolveCachedAgentTaskRecoveryWithResult; +const endpoint = "https://chatgpt.com/backend-api/codex/responses"; +let sequence = 0; + +/** Own the shared request callback, which can outlive its cancelled caller. */ +export function createAgentTaskRecoveryFixture() { + const marker = `recovery-fixture-${++sequence}`; + const pending = new Set>(); + const releases: Array<() => void> = []; + const unexpected: Error[] = []; + const foreign = new WeakSet(); + const abort = new AbortController(); + let isClosing = false; + let closing: Promise | undefined; + const track = (work: Promise): Promise => { + pending.add(work); + void work.then(() => pending.delete(work), () => pending.delete(work)); + return work; + }; + const resolver = spyOn(cache, "resolveCachedAgentTaskRecoveryWithResult").mockImplementation( + (key, maxEntries, request, signal) => resolveRecovery(key, maxEntries, abort => track(request(abort)), signal), + ); + return { + marker, + track, + signal: abort.signal, + request: () => new Request("http://localhost/v1/responses", { + headers: codexHeaders(marker, { "user-agent": marker }), + }), + releaseOnClose: (release: () => void) => { releases.push(release); }, + expectForeignFetch: (request: Request) => { foreign.add(request); }, + stub(handler: typeof fetch): void { + globalThis.fetch = (async (input, init) => { + const request = input instanceof Request ? input : null; + const headers = new Headers(init?.headers ?? request?.headers); + if (isClosing || String(request?.url ?? input) !== endpoint || headers.get("user-agent") !== marker) { + const error = new Error("foreign recovery fetch"); + if (!request || !foreign.delete(request)) unexpected.push(error); + throw error; + } + return handler(input, init); + }) as typeof fetch; + }, + close(): Promise { + isClosing = true; + return closing ??= (async () => { + const failures: unknown[] = []; + try { + abort.abort(); + cache.resetAgentTaskRecoveryCache(); + for (const release of releases) { + try { release(); } catch (error) { failures.push(error); } + } + // A handler can register its recovery callback while teardown is starting. + while (pending.size) { + const results = await Promise.allSettled([...pending]); + for (const result of results) { + if (result.status === "rejected" && result.reason !== abort.signal.reason) failures.push(result.reason); + } + } + cache.resetAgentTaskRecoveryCache(); + failures.push(...unexpected); + } finally { + resolver.mockRestore(); + globalThis.fetch = originalFetch; + } + if (failures.length) throw new AggregateError(failures, "recovery fixture cleanup failed"); + })(); + }, + }; +} diff --git a/tests/server/agent-task-recovery.test.ts b/tests/server/agent-task-recovery.test.ts index 20893c53a1b..fddd76a43d1 100644 --- a/tests/server/agent-task-recovery.test.ts +++ b/tests/server/agent-task-recovery.test.ts @@ -19,7 +19,7 @@ import { FINAL_ANSWER_ENVELOPE, FINAL_ANSWER_TASK_ENVELOPE, originalFetch, - post, + post as handlePost, providerResponse, recoveryArgumentsDoneSse, recoveryCompletedSse, @@ -29,15 +29,129 @@ import { SECOND_FERNET_TASK, } from "../helpers/agent-task-recovery"; import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { createAgentTaskRecoveryFixture } from "../helpers/agent-task-recovery-fixture"; +import { resolveCachedAgentTaskRecoveryWithResult } from "../../src/server/responses/agent-task-recovery-cache"; + +test("fixture rejects a foreign recovery before it can advance this case's counter", async () => { + const fixture = caseFixture; + let sends = 0; + fixture.stub((async () => { sends++; return new Response(recoverySse("fixture")); }) as typeof fetch); + const foreign = new Request("https://chatgpt.com/backend-api/codex/responses", { + headers: codexHeaders("previous-case", { "user-agent": "previous-case" }), + }); + fixture.expectForeignFetch(foreign); + try { + await expect(fetch(foreign)).rejects.toThrow("foreign recovery fetch"); + expect(sends).toBe(0); + } finally { + await fixture.close(); + } +}); + +test("fixture reports unexpected fetches even when recovery catches their rejection", async () => { + const fixture = caseFixture; + fixture.stub((async () => new Response(recoverySse("must not run"))) as typeof fetch); + const foreign = new Request("http://localhost/v1/responses", { headers: codexHeaders("unexpected-case") }); + expect(await recoverEncryptedAgentTaskWithResult(foreign, encryptedInput(), {}, routedConfig())) + .toEqual({ recovered: false, reason: "recovery_transport_error" }); + try { + await expect(fixture.close()).rejects.toThrow("recovery fixture cleanup failed"); + } finally { + // The deliberately rejected fixture is complete; keep ordinary afterEach independent. + caseFixture = createAgentTaskRecoveryFixture(); + } +}); + +test("fixture close waits for shared work after caller cancellation and fetch settlement", async () => { + const fixture = caseFixture; + let release!: () => void; + const body = new Promise(resolve => { release = resolve; }); + const caller = new AbortController(); + let fetchSettled = false; + let workSettled = false; + let closed = false; + fixture.stub((async () => new Response("fixture")) as typeof fetch); + const installedFetch = globalThis.fetch; + const pending = resolveCachedAgentTaskRecoveryWithResult(fixture.marker, 200, async () => { + await fetch("https://chatgpt.com/backend-api/codex/responses", { headers: fixture.request().headers }); + fetchSettled = true; + await body; + workSettled = true; + return { recovered: false, reason: "recovery_aborted" }; + }, caller.signal); + // Let the synthetic mock's response settle while its owned body work remains blocked. + await Promise.resolve(); + await Promise.resolve(); + caller.abort(); + expect(await pending).toEqual({ recovered: false, reason: "caller_cancelled" }); + const closing = fixture.close().then(() => { closed = true; }); + try { + await Promise.resolve(); + expect(fetchSettled).toBe(true); + expect(workSettled).toBe(false); + expect(closed).toBe(false); + expect(globalThis.fetch).toBe(installedFetch); + release(); + await closing; + expect(workSettled).toBe(true); + expect(globalThis.fetch).toBe(originalFetch); + } finally { + release(); + await closing; + } +}); + +test("fixture close releases a gated recovery after an assertion fails", async () => { + const fixture = caseFixture; + const events: string[] = []; + let release!: () => void; + let started!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const ready = new Promise(resolve => { started = resolve; }); + fixture.releaseOnClose(() => { events.push("released"); release(); }); + fixture.stub((async () => { + started(); + await gate; + events.push("mock-settled"); + return new Response(recoverySse("fixture")); + }) as typeof fetch); + const pending = fixture.track(recoverEncryptedAgentTaskWithResult(fixture.request(), encryptedInput(), {}, routedConfig())); + try { + await ready; + let assertion: unknown; + try { + expect("unreleased gate").toBe("released gate"); + } catch (error) { + assertion = error; + } finally { + await fixture.close(); + } + expect(assertion).toBeDefined(); + expect(events).toEqual(["released", "mock-settled"]); + expect(await pending).toEqual({ recovered: false, reason: "recovery_aborted" }); + } finally { + release(); + await pending; + await fixture.close(); + } +}); // Direct handler dispatch never takes the writer lease that startServer would take, so it is refused. let releaseSpendHome: (() => void) | undefined; +let caseFixture: ReturnType; +const post: typeof handlePost = (...args) => { + args[4] = args[4] ? AbortSignal.any([args[4], caseFixture.signal]) : caseFixture.signal; + return caseFixture.track(handlePost(...args)); +}; beforeEach(() => { releaseSpendHome = acquireOwnedSpendHome(); + caseFixture = createAgentTaskRecoveryFixture(); }); -afterEach(() => { - releaseSpendHome?.(); - releaseSpendHome = undefined; +afterEach(async () => { + try { await caseFixture.close(); } finally { + releaseSpendHome?.(); + releaseSpendHome = undefined; + } }); describe("agent task recovery (opt-in, default off)", () => { @@ -45,10 +159,6 @@ describe("agent task recovery (opt-in, default off)", () => { resetAgentTaskRecoveryState(); }); - afterEach(() => { - globalThis.fetch = originalFetch; - resetAgentTaskRecoveryState(); - }); for (const messageType of ["NEW_TASK", "MESSAGE", "FOLLOWUP_TASK"] as const) { test(`typed ${messageType} recovery preserves boolean, replay and discard contracts`, async () => { @@ -144,7 +254,7 @@ describe("agent task recovery (opt-in, default off)", () => { let cancels = 0; let reads = 0; let releaseCancel: (() => void) | undefined; - const cancellation = new Promise(resolve => { releaseCancel = resolve; }); + const cancellation = caseFixture.track(new Promise(resolve => { releaseCancel = resolve; })); globalThis.fetch = (async () => new Response(new ReadableStream({ pull() { reads++; }, cancel() { @@ -180,7 +290,7 @@ describe("agent task recovery (opt-in, default off)", () => { pull(controller) { controller.enqueue(new Uint8Array([0xe2, 0x82])); started(); - return new Promise(() => {}); + return caseFixture.track(new Promise(resolve => { caseFixture.releaseOnClose(resolve); })); }, }, { highWaterMark: 0 }))); return new Promise((_resolve, reject) => { @@ -751,6 +861,7 @@ describe("agent task recovery (opt-in, default off)", () => { }) as typeof fetch; const headers = codexHeaders("acct-flight", { "x-codex-parent-thread-id": "parent-flight" }); + caseFixture.releaseOnClose(() => releaseRecovery?.()); const first = post(routedConfig(), "xai/grok-4.5", encryptedInput(), headers); const second = post(routedConfig(), "xai/grok-4.5", encryptedInput(), headers); for (let turn = 0; turn < 200 && agentTaskRecoveryWaiterCountForTests() < 2; turn += 1) { @@ -889,10 +1000,6 @@ describe("mid-thread encrypted agent task recovery (#4089)", () => { resetAgentTaskRecoveryState(); }); - afterEach(() => { - globalThis.fetch = originalFetch; - resetAgentTaskRecoveryState(); - }); /** A mid-thread model switch: same Codex caller, same credentials, no spawn markers. */ const midThreadHeaders = (accountId = "acct-caller"): Headers => { @@ -998,7 +1105,6 @@ describe("mid-thread encrypted agent task recovery (#4089)", () => { describe("bounded multipart encrypted task recovery", () => { beforeEach(() => resetAgentTaskRecoveryState()); - afterEach(() => { globalThis.fetch = originalFetch; resetAgentTaskRecoveryState(); }); const multipart = (tokens: string[] = [FERNET_TASK, SECOND_FERNET_TASK]) => agentMessage([ { type: "input_text", text: ROUTING_ENVELOPE }, ...tokens.map(encrypted_content => ({ type: "encrypted_content", encrypted_content })), @@ -1080,6 +1186,7 @@ describe("bounded multipart encrypted task recovery", () => { let started!: () => void; const ready = new Promise(resolve => { started = resolve; }); globalThis.fetch = (() => { started(); return new Promise(resolve => { release = resolve; }); }) as typeof fetch; + caseFixture.releaseOnClose(() => release(new Response(recoverySse("Abandoned mutation fixture.")))); const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); const input = multipart(); const pending = recoverEncryptedAgentTaskWithResult(req, input, {}, routedConfig()); @@ -1131,7 +1238,6 @@ describe("bounded multipart encrypted task recovery", () => { describe("FINAL_ANSWER encrypted task recovery", () => { beforeEach(() => resetAgentTaskRecoveryState()); - afterEach(() => { globalThis.fetch = originalFetch; resetAgentTaskRecoveryState(); }); test("recovers a FINAL_ANSWER without a Task name line and replays it from cache", async () => { const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); @@ -1257,7 +1363,6 @@ describe("FINAL_ANSWER encrypted task recovery", () => { describe("recovery refuses a wrong-family echoed routing header", () => { beforeEach(() => resetAgentTaskRecoveryState()); - afterEach(() => { globalThis.fetch = originalFetch; resetAgentTaskRecoveryState(); }); const echoCases: Array<[string, string, string]> = [ ["FINAL_ANSWER envelope echoing a NEW_TASK header", FINAL_ANSWER_ENVELOPE, `${ROUTING_ENVELOPE}Recovered final answer.`], @@ -1288,18 +1393,17 @@ describe("recovery refuses a wrong-family echoed routing header", () => { describe("agent task recovery transient retry (#3661)", () => { beforeEach(() => resetAgentTaskRecoveryState()); - afterEach(() => { globalThis.fetch = originalFetch; resetAgentTaskRecoveryState(); }); - const req = () => new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + const req = () => caseFixture.request(); test.each([500, 502, 503, 504, 520] as const)("a transient %d refusal retries within the configured budget and recovers", async status => { let fetches = 0; - globalThis.fetch = (async () => { + caseFixture.stub((async () => { fetches += 1; return fetches === 1 ? new Response("private failure", { status }) : new Response(recoverySse("Recovered after outage.")); - }) as typeof fetch; + }) as typeof fetch); const input = encryptedInput(); expect(await recoverEncryptedAgentTaskWithResult(req(), input, { retries: 1 }, routedConfig())) .toEqual({ recovered: true }); @@ -1310,10 +1414,10 @@ describe("agent task recovery transient retry (#3661)", () => { test.each([400, 401, 403, 404, 429] as const)("a %d refusal stays terminal under a configured retry budget", async status => { let fetches = 0; - globalThis.fetch = (async () => { + caseFixture.stub((async () => { fetches += 1; return new Response("private failure", { status }); - }) as typeof fetch; + }) as typeof fetch); expect(await recoverEncryptedAgentTaskWithResult(req(), encryptedInput(), { retries: 2 }, routedConfig())) .toEqual({ recovered: false, reason: "recovery_http_rejected" }); expect(fetches).toBe(1); @@ -1321,10 +1425,10 @@ describe("agent task recovery transient retry (#3661)", () => { test("persistent transient rejections exhaust the bounded retry budget", async () => { let fetches = 0; - globalThis.fetch = (async () => { + caseFixture.stub((async () => { fetches += 1; return new Response("private failure", { status: 503 }); - }) as typeof fetch; + }) as typeof fetch); expect(await recoverEncryptedAgentTaskWithResult(req(), encryptedInput(), { retries: 2 }, routedConfig())) .toEqual({ recovered: false, reason: "recovery_http_rejected" }); expect(fetches).toBe(3); // 1 initial + 2 retries @@ -1332,10 +1436,10 @@ describe("agent task recovery transient retry (#3661)", () => { test("an over-budget retries value is clamped to the same bound", async () => { let fetches = 0; - globalThis.fetch = (async () => { + caseFixture.stub((async () => { fetches += 1; return new Response("private failure", { status: 503 }); - }) as typeof fetch; + }) as typeof fetch); expect(await recoverEncryptedAgentTaskWithResult(req(), encryptedInput(), { retries: 99 }, routedConfig())) .toEqual({ recovered: false, reason: "recovery_http_rejected" }); expect(fetches).toBe(3); @@ -1343,11 +1447,11 @@ describe("agent task recovery transient retry (#3661)", () => { test("transport failures retry while invalid recovery output stays terminal", async () => { let fetches = 0; - globalThis.fetch = (async () => { + caseFixture.stub((async () => { fetches += 1; if (fetches === 1) throw new TypeError("private transport failure"); return new Response("data: {not-json}\n\n"); - }) as typeof fetch; + }) as typeof fetch); expect(await recoverEncryptedAgentTaskWithResult(req(), encryptedInput(), { retries: 2 }, routedConfig())) .toEqual({ recovered: false, reason: "recovery_invalid_output" }); expect(fetches).toBe(2); @@ -1355,10 +1459,10 @@ describe("agent task recovery transient retry (#3661)", () => { test("persistent transport failures exhaust the same bounded budget", async () => { let fetches = 0; - globalThis.fetch = (async () => { + caseFixture.stub((async () => { fetches += 1; throw new TypeError("private transport failure"); - }) as typeof fetch; + }) as typeof fetch); expect(await recoverEncryptedAgentTaskWithResult(req(), encryptedInput(), { retries: 2 }, routedConfig())) .toEqual({ recovered: false, reason: "recovery_transport_error" }); expect(fetches).toBe(3); @@ -1367,10 +1471,10 @@ describe("agent task recovery transient retry (#3661)", () => { test("caller cancellation during retry backoff reports caller_cancelled without another send", async () => { const caller = new AbortController(); let fetches = 0; - globalThis.fetch = (async () => { + caseFixture.stub((async () => { fetches += 1; return new Response("private failure", { status: 503 }); - }) as typeof fetch; + }) as typeof fetch); const pending = recoverEncryptedAgentTaskWithResult( req(), encryptedInput(), { retries: 2 }, routedConfig(), { abortSignal: caller.signal }, ); @@ -1382,10 +1486,10 @@ describe("agent task recovery transient retry (#3661)", () => { test("a Retry-After past the recovery deadline ends with the failure instead of resending early", async () => { let fetches = 0; - globalThis.fetch = (async () => { + caseFixture.stub((async () => { fetches += 1; return new Response("private failure", { status: 503, headers: { "retry-after": "30" } }); - }) as typeof fetch; + }) as typeof fetch); const started = Date.now(); expect(await recoverEncryptedAgentTaskWithResult(req(), encryptedInput(), { retries: 2, timeoutMs: 5_000 }, routedConfig())) .toEqual({ recovered: false, reason: "recovery_http_rejected" }); @@ -1398,7 +1502,7 @@ describe("agent task recovery transient retry (#3661)", () => { let fetches = 0; let firstAt = 0; let secondAt = 0; - globalThis.fetch = (async () => { + caseFixture.stub((async () => { fetches += 1; if (fetches === 1) { firstAt = Date.now(); @@ -1406,7 +1510,7 @@ describe("agent task recovery transient retry (#3661)", () => { } secondAt = Date.now(); return new Response(recoverySse("Recovered after outage.")); - }) as typeof fetch; + }) as typeof fetch); expect(await recoverEncryptedAgentTaskWithResult(req(), encryptedInput(), { retries: 1 }, routedConfig())) .toEqual({ recovered: true }); expect(fetches).toBe(2); From a033a1a948f5cf78e4963f52cb8b666d9ab07602 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 24 Oct 2026 21:45:32 +0900 Subject: [PATCH 019/101] fix(gui): cancel Remote Link sheet focus work (#7611) (#7650) --- gui/src/pages/RemoteLink.tsx | 3 +- gui/tests/remote-link.test.tsx | 58 +++++++++++++++++++++++++++++++--- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/gui/src/pages/RemoteLink.tsx b/gui/src/pages/RemoteLink.tsx index cec347cec63..02c2986294a 100644 --- a/gui/src/pages/RemoteLink.tsx +++ b/gui/src/pages/RemoteLink.tsx @@ -254,7 +254,8 @@ export default function RemoteLink({ apiBase, sessionReady, pairingRecoveryAvail if (dialog && !dialog.open) { if (typeof dialog.showModal === "function") dialog.showModal(); else dialog.setAttribute("open", ""); } - window.setTimeout(() => dialog?.querySelector("input, button")?.focus(), 0); + const timer = window.setTimeout(() => dialog?.querySelector("input, button")?.focus(), 0); + return () => window.clearTimeout(timer); }, [sheetOpen]); useEffect(() => { diff --git a/gui/tests/remote-link.test.tsx b/gui/tests/remote-link.test.tsx index f5c92f1123d..a91a71e5bc7 100644 --- a/gui/tests/remote-link.test.tsx +++ b/gui/tests/remote-link.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, expect, test } from "bun:test"; +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; import { Window } from "happy-dom"; import type { Root } from "react-dom/client"; import { act } from "react"; @@ -24,9 +24,14 @@ beforeEach(() => { }); afterEach(async () => { - if (root) await act(async () => { root?.unmount(); }); - root = null; - for (const key of globals) Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] }); + try { + if (root) await act(async () => { root?.unmount(); }); + } finally { + root = null; + try { await win.happyDOM.close(); } finally { + for (const key of globals) Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] }); + } + } }); function response(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); } @@ -883,6 +888,51 @@ async function enterAlias(sheet: HTMLElement, value: string): Promise { }); } +test.each(["close", "unmount"] as const)("pending sheet focus is cancelled on %s", async transition => { + type Timer = ReturnType; + const pending = new Map void>(); + const schedule = win.setTimeout.bind(win); + const cancel = win.clearTimeout.bind(win); + const timers = spyOn(win, "setTimeout").mockImplementation((callback, delay = 0, ...args) => { + if (delay !== 0) return schedule(callback, delay, ...args); + const timer = {} as Timer; + pending.set(timer, () => callback(...args)); + return timer; + }); + const cancellations = spyOn(win, "clearTimeout").mockImplementation(timer => { + if (!pending.delete(timer)) cancel(timer); + }); + globalThis.fetch = (async input => response(String(input).endsWith("/candidates") ? { candidates: [] } : baseStatus)) as typeof fetch; + try { + const host = await mount(); + const sheet = await openHomeSheet(host); + const trigger = [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Add child"))!; + expect(pending.size).toBe(1); + // Prove the held callback is live and still gives an open sheet its initial focus. + for (const [timer, callback] of pending) { pending.delete(timer); callback(); } + expect(win.document.activeElement).toBe(sheet.querySelector("input, button")); + await act(async () => { sheet.dispatchEvent(new win.Event("cancel", { cancelable: true })); }); + expect(win.document.activeElement).toBe(trigger); + await act(async () => { trigger.click(); }); + expect(pending.size).toBe(1); + if (transition === "close") { + await act(async () => { sheet.dispatchEvent(new win.Event("cancel", { cancelable: true })); }); + } else { + await act(async () => { root?.unmount(); }); + root = null; + } + // Check cancellation before draining; executing an uncancelled timer cannot make this pass. + expect(pending.size).toBe(0); + for (const callback of pending.values()) callback(); + if (transition === "close") expect(win.document.activeElement).toBe(trigger); + } finally { + if (root) await act(async () => { root?.unmount(); }); + root = null; + timers.mockRestore(); + cancellations.mockRestore(); + } +}); + test("discovery loading, empty setup and rescan retain keyboard-accessible manual entry", async () => { let complete!: (value: Response) => void; let calls = 0; From 047c5dc60695351657e1f6ea7b85e12015cb67e4 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 24 Oct 2026 21:46:51 +0900 Subject: [PATCH 020/101] feat(gui): expose JEV session pin in Combo editor (#7664) * feat(gui): add JEV session pin controls and preserve save intent * fix(gui): narrow the selected source for saved combo baselines * fix(gui): guard JEV session pin numeric input parsing --- docs-site/src/content/docs/guides/combos.md | 6 + gui/src/combo-workspace-data.ts | 13 + gui/src/components/ComboWorkspace.tsx | 10 +- .../components/combo-workspace-add-modal.tsx | 1 + .../combo-workspace-detail-panel.tsx | 5 +- .../combo-workspace-jev-decision.tsx | 53 +++++ gui/src/components/combo-workspace-utils.ts | 4 + gui/src/i18n/de.ts | 4 + gui/src/i18n/en.ts | 4 + gui/src/i18n/fr.ts | 4 + gui/src/i18n/ja.ts | 4 + gui/src/i18n/ko.ts | 4 + gui/src/i18n/pt.ts | 4 + gui/src/i18n/ru.ts | 4 + gui/src/i18n/tr.ts | 4 + gui/src/i18n/vi.ts | 4 + gui/src/i18n/zh-TW.ts | 4 + gui/src/i18n/zh.ts | 4 + gui/tests/jev-session-pin.test.tsx | 225 ++++++++++++++++++ structure/providers/jev-decision.md | 4 + 20 files changed, 358 insertions(+), 7 deletions(-) create mode 100644 gui/tests/jev-session-pin.test.tsx diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index 6f35ed79f59..55faba32e8d 100644 --- a/docs-site/src/content/docs/guides/combos.md +++ b/docs-site/src/content/docs/guides/combos.md @@ -279,6 +279,12 @@ between providers, and each move starts with a cold provider prompt cache. `sess opt-in setting per JEV Combo, off by default, that keeps a client session on the target JEV chose for it while the session stays active: +In the dashboard, open the JEV Combo editor's **Decision method** section and enable +**Keep a session on its decided target**, then set **Idle window (ms)** and save. The range is +60000–86400000 ms (1 minute–24 hours); newly enabled pins start at 60000 ms. Disable the checkbox +and save to turn pinning off. Invalid or empty enabled windows block saving; failed saves keep +your edits for retry. Older responses without this setting leave it omitted until you change it. + ```bash ocx combo set jev-auto --session-pin 20m --json ``` diff --git a/gui/src/combo-workspace-data.ts b/gui/src/combo-workspace-data.ts index 88c9b9b0528..84bbc12198c 100644 --- a/gui/src/combo-workspace-data.ts +++ b/gui/src/combo-workspace-data.ts @@ -5,6 +5,7 @@ import { SUPPORTED_NATIVE_OPENAI_SLUGS } from "../../src/codex/catalog/native-models"; import { PROVIDER_QUOTA_MAX_AGE_MS } from "../../src/providers/quota-types"; +import { normalizeJevSessionPin, type JevSessionPinConfig } from "../../src/combos/jev-session-pin-config"; import type { TKey } from "./i18n/shared"; import { CANONICAL_JEV_DECISION_PROVIDER, @@ -171,6 +172,8 @@ export interface ComboItem { decisionModel?: string | null; /** `jev` only: decision deadline in ms; null/omitted = the server default. */ decisionTimeoutMs?: number | null; + /** `jev` only: undefined preserves an omitted setting; null explicitly turns the pin off. */ + sessionPin?: JevSessionPinConfig | null; targets: ComboTarget[]; } @@ -300,6 +303,7 @@ export function parseComboList(payload: unknown): ComboItem[] { const decisionProvider = normalizeDecisionProvider(r.decisionProvider); const decisionModel = normalizeAlias(r.decisionModel); const decisionTimeoutMs = normalizeDecisionTimeoutMs(r.decisionTimeoutMs); + const sessionPin = r.sessionPin === null ? null : normalizeJevSessionPin(r.sessionPin); out.push({ id, model: typeof r.model === "string" && r.model.trim() @@ -317,6 +321,7 @@ export function parseComboList(payload: unknown): ComboItem[] { ...(decisionProvider !== null ? { decisionProvider } : {}), ...(decisionModel !== null ? { decisionModel } : {}), ...(decisionTimeoutMs !== null ? { decisionTimeoutMs } : {}), + ...(sessionPin !== undefined ? { sessionPin } : {}), targets, }); } @@ -491,6 +496,8 @@ export function draftEquals(a: ComboItem, b: ComboItem): boolean { (a.decisionProvider ?? null) !== (b.decisionProvider ?? null) || (a.decisionModel ?? null) !== (b.decisionModel ?? null) || (a.decisionTimeoutMs ?? null) !== (b.decisionTimeoutMs ?? null) + || (a.sessionPin === undefined) !== (b.sessionPin === undefined) + || (a.sessionPin?.ttlMs ?? null) !== (b.sessionPin?.ttlMs ?? null) )) ) return false; if (a.targets.length !== b.targets.length) return false; @@ -524,6 +531,7 @@ export function toPutBody( decisionProvider?: string | null; decisionModel?: string | null; decisionTimeoutMs?: number | null; + sessionPin?: JevSessionPinConfig | null; }; } { const weighted = item.strategy === "round-robin" || item.strategy === "random"; @@ -563,6 +571,7 @@ export function toPutBody( decisionProvider: item.decisionModel?.trim() ? null : normalizeDecisionProvider(item.decisionProvider), decisionModel: item.decisionModel?.trim() || null, decisionTimeoutMs: item.decisionTimeoutMs ?? null, + ...(item.sessionPin !== undefined ? { sessionPin: item.sessionPin } : {}), } : {}), }, @@ -591,6 +600,7 @@ export type ComboDraftError = | "invalidReasoningEfforts" | "invalidModelProfile" | "invalidDecisionTimeout" + | "invalidSessionPin" | "invalidDecisionProvider" | "invalidDecisionModel" | "noEnabledTarget"; @@ -674,6 +684,9 @@ export function validateComboDraft( } } + if (item.strategy === "jev" && item.sessionPin != null && !normalizeJevSessionPin(item.sessionPin)) { + return "invalidSessionPin"; + } if (item.strategy === "jev" && item.decisionTimeoutMs != null && (!Number.isInteger(item.decisionTimeoutMs) || item.decisionTimeoutMs < JEV_DECISION_TIMEOUT_MIN_MS diff --git a/gui/src/components/ComboWorkspace.tsx b/gui/src/components/ComboWorkspace.tsx index 1f513da07a6..30572ea81a2 100644 --- a/gui/src/components/ComboWorkspace.tsx +++ b/gui/src/components/ComboWorkspace.tsx @@ -53,7 +53,7 @@ export default function ComboWorkspace({ const [selectedId, setSelectedId] = useState(null); const [pendingSelect, setPendingSelect] = useState(undefined); const [removeId, setRemoveId] = useState(null); - const [localBaseline, setLocalBaseline] = useState(null); + const [localBaseline, setLocalBaseline] = useState<{ source: ComboItem; item: ComboItem } | null>(null); const firstComboDraft = useMemo(() => emptyDraft(), []); const jevAutoExists = useMemo( () => combos.some(combo => combo.id === "jev-auto" || combo.alias === "jev-auto"), @@ -83,7 +83,7 @@ export default function ComboWorkspace({ ); const activeId = selectedId && combos.some((c) => c.id === selectedId) ? selectedId : null; const selected = combos.find((c) => c.id === activeId) ?? null; - const baseline = selected && localBaseline?.id === selected.id ? localBaseline : selected; + const baseline = selected && localBaseline?.source === selected ? localBaseline.item : selected; const [detailDirty, setDetailDirty] = useState(false); @@ -214,7 +214,7 @@ export default function ComboWorkspace({
- {baseline ? ( + {baseline && selected ? ( { setDetailDirty(false); setSelectedId(item.id); - setLocalBaseline(item); + setLocalBaseline(null); onCreated(item.id); }} onSave={onSave} diff --git a/gui/src/components/combo-workspace-add-modal.tsx b/gui/src/components/combo-workspace-add-modal.tsx index 6ae3c6c3363..a085e5e56e8 100644 --- a/gui/src/components/combo-workspace-add-modal.tsx +++ b/gui/src/components/combo-workspace-add-modal.tsx @@ -204,6 +204,7 @@ export function AddComboModal({ decisionProvider={draft.decisionProvider ?? null} decisionModel={draft.decisionModel ?? null} decisionTimeoutMs={draft.decisionTimeoutMs ?? null} + sessionPin={draft.sessionPin} disabled={busy} onChange={(patch) => setDraft((d) => ({ ...d, ...patch }))} /> diff --git a/gui/src/components/combo-workspace-detail-panel.tsx b/gui/src/components/combo-workspace-detail-panel.tsx index a191212a845..f42b9d38a3f 100644 --- a/gui/src/components/combo-workspace-detail-panel.tsx +++ b/gui/src/components/combo-workspace-detail-panel.tsx @@ -106,7 +106,7 @@ export function DetailPanel({ const [copied, setCopied] = useState(false); const dirty = !draftEquals(draft, baseline); const allTargetsExhausted = comboQuotaState(draft.targets, providerQuotaStates, providerMap) === "exhausted"; - const baselineSyncKey = JSON.stringify([baseline.id, baseline.alias, baseline.nativeAlias, baseline.displayName, baseline.strategy, baseline.stickyLimit, baseline.defaultEffort, baseline.imageInput, baseline.reasoningEffortMode, baseline.decisionProvider, baseline.decisionModel, baseline.decisionTimeoutMs, baseline.targets.map(t => [t.provider, t.model, t.weight, t.reasoningEfforts, t.modelProfile])]); + const baselineSyncKey = JSON.stringify([baseline.id, baseline.alias, baseline.nativeAlias, baseline.displayName, baseline.strategy, baseline.stickyLimit, baseline.defaultEffort, baseline.imageInput, baseline.reasoningEffortMode, baseline.decisionProvider, baseline.decisionModel, baseline.decisionTimeoutMs, baseline.sessionPin === undefined, baseline.sessionPin, baseline.targets.map(t => [t.provider, t.model, t.weight, t.reasoningEfforts, t.modelProfile])]); const effortMap = useMemo(() => { const map = new Map(); for (const model of capabilityModels) { @@ -170,7 +170,7 @@ export function DetailPanel({ displayName, model: comboPublicModelId(trimmedId, alias), // The server keeps these only for JEV, so the saved baseline must not carry stale ones. - ...(draft.strategy === "jev" ? {} : { decisionProvider: null, decisionModel: null, decisionTimeoutMs: null }), + ...(draft.strategy === "jev" ? {} : { decisionProvider: null, decisionModel: null, decisionTimeoutMs: null, sessionPin: undefined }), }; const renameFrom = !isCreate && trimmedId !== baseline.id ? baseline.id : undefined; try { @@ -358,6 +358,7 @@ export function DetailPanel({ decisionProvider={draft.decisionProvider ?? null} decisionModel={draft.decisionModel ?? null} decisionTimeoutMs={draft.decisionTimeoutMs ?? null} + sessionPin={draft.sessionPin} disabled={busy} onChange={(patch) => updateDraft((d) => ({ ...d, ...patch }))} /> diff --git a/gui/src/components/combo-workspace-jev-decision.tsx b/gui/src/components/combo-workspace-jev-decision.tsx index f35c79db112..c2242ed52a1 100644 --- a/gui/src/components/combo-workspace-jev-decision.tsx +++ b/gui/src/components/combo-workspace-jev-decision.tsx @@ -1,4 +1,10 @@ import { useEffect, useMemo, useRef, useState } from "react"; +import { + JEV_SESSION_PIN_TTL_MIN_MS, + JEV_SESSION_PIN_TTL_MAX_MS, + normalizeJevSessionPin, + type JevSessionPinConfig, +} from "../../../src/combos/jev-session-pin-config"; import { JEV_DECISION_ISSUE_LABEL_KEYS, jevDecisionServiceOptions } from "../combo-workspace-data"; import { type TKey, useT } from "../i18n/shared"; import { @@ -16,6 +22,7 @@ export type JevDecisionPatch = { decisionProvider?: string | null; decisionModel?: string | null; decisionTimeoutMs?: number | null; + sessionPin?: JevSessionPinConfig | null; }; type DecisionTestResult = @@ -46,6 +53,7 @@ export function ComboJevDecisionSection({ decisionProvider, decisionModel, decisionTimeoutMs, + sessionPin, disabled, onChange, }: { @@ -59,11 +67,13 @@ export function ComboJevDecisionSection({ decisionProvider: string | null; decisionModel: string | null; decisionTimeoutMs: number | null; + sessionPin?: JevSessionPinConfig | null; disabled?: boolean; onChange: (patch: JevDecisionPatch) => void; }) { const t = useT(); const method = jevDecisionMethod({ decisionProvider, decisionModel }); + const pinInvalid = sessionPin != null && !normalizeJevSessionPin(sessionPin); const servers = useMemo( () => jevDecisionServiceOptions(providers, decisionProvider).filter(option => option.id !== null), [providers, decisionProvider], @@ -271,6 +281,49 @@ export function ComboJevDecisionSection({

+
+ +

+ {t("cws.jev.sessionPinHint", { min: JEV_SESSION_PIN_TTL_MIN_MS, max: JEV_SESSION_PIN_TTL_MAX_MS })} +

+ {sessionPin != null && <> + + { + if (e.target.value === "") { + onChange({ sessionPin: { ttlMs: 0 } }); + return; + } + const ttlMs = Number(e.target.value); + onChange({ sessionPin: { ttlMs: Number.isFinite(ttlMs) ? ttlMs : 0 } }); + }} + /> + {pinInvalid && } + } +
+
+
{profile.configPath}
+ {profile.error && {profile.error}} +
+
+ +
+ + ))} + + + )} + {resource.state.showError && ( + + {t("integrations.hermes.loadError")} + + + )} + void resource.refresh()} + /> + + ); +} diff --git a/gui/src/pages/integrations/RestoreDialog.tsx b/gui/src/pages/integrations/RestoreDialog.tsx index fea64673d7a..bb527688827 100644 --- a/gui/src/pages/integrations/RestoreDialog.tsx +++ b/gui/src/pages/integrations/RestoreDialog.tsx @@ -39,6 +39,7 @@ export default function RestoreDialog({ onRestored, profileId, onReconcile, + hermesProfile, }: { apiBase: string; row: IntegrationJournalRow; @@ -46,6 +47,8 @@ export default function RestoreDialog({ onRestored: () => void; profileId?: number; onReconcile?: () => void; + /** The named Hermes profile whose history this row came from. */ + hermesProfile?: string; }) { const t = useT(); const dialogRef = useRef(null); @@ -65,6 +68,7 @@ export default function RestoreDialog({ const [previewFailure, setPreviewFailure] = useState(null); const [mutationFailure, setMutationFailure] = useState(null); const scopedProfileId = row.clientId === "aside" ? profileId ?? row.profileId : undefined; + const scopedHermesProfile = row.clientId === "hermes" ? hermesProfile : undefined; const plan = boundPlan?.plan ?? null; const drift = plan?.foreignEdit === "drift"; @@ -140,10 +144,10 @@ export default function RestoreDialog({ setStale(false); try { let confirmDrift = false; - let next = await previewIntegrationRestore(apiBase, row.opId, confirmDrift, controller.signal, scopedProfileId); + let next = await previewIntegrationRestore(apiBase, row.opId, confirmDrift, controller.signal, scopedProfileId, scopedHermesProfile); if (next.refusalReason === "drift_requires_confirm") { confirmDrift = true; - next = await previewIntegrationRestore(apiBase, row.opId, confirmDrift, controller.signal, scopedProfileId); + next = await previewIntegrationRestore(apiBase, row.opId, confirmDrift, controller.signal, scopedProfileId, scopedHermesProfile); } if (controller.signal.aborted || generation !== previewGenerationRef.current) return; setBoundPlan({ plan: next, confirmDrift }); @@ -161,7 +165,7 @@ export default function RestoreDialog({ setPreviewPending(false); } } - }, [apiBase, row.opId, scopedProfileId]); + }, [apiBase, row.opId, scopedProfileId, scopedHermesProfile]); useEffect(() => { const timeout = window.setTimeout(() => { void loadPreview(); }, 0); @@ -188,6 +192,7 @@ export default function RestoreDialog({ opId: row.opId, confirmDrift: attemptedConfirmDrift, profileId: scopedProfileId, + hermesProfile: scopedHermesProfile, binding: bindingFor(boundPlan.plan), }); restoredRef.current = true; @@ -211,7 +216,7 @@ export default function RestoreDialog({ setPreviewPending(true); freshConfirmDrift = true; try { - fresh = await previewIntegrationRestore(apiBase, row.opId, freshConfirmDrift, controller.signal, scopedProfileId); + fresh = await previewIntegrationRestore(apiBase, row.opId, freshConfirmDrift, controller.signal, scopedProfileId, scopedHermesProfile); } finally { if (!controller.signal.aborted && generation === previewGenerationRef.current) { previewAbortRef.current = null; diff --git a/gui/src/pages/integrations/hermes-profile-api.ts b/gui/src/pages/integrations/hermes-profile-api.ts new file mode 100644 index 00000000000..c9855b7cfe7 --- /dev/null +++ b/gui/src/pages/integrations/hermes-profile-api.ts @@ -0,0 +1,37 @@ +import { IntegrationApiError, readIntegrationResponse, type IntegrationState } from "./integration-api"; + +/** One Hermes profile as GET /api/client-integrations/hermes/profiles reports it (#7674). */ +export interface HermesProfileStatus { + hermesProfile: string; + configPath: string; + state: IntegrationState; + installed: boolean; + error?: string; +} + +const STATES: readonly IntegrationState[] = ["absent", "current", "stale", "conflict", "unsafe"]; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function invalid(): never { + throw new IntegrationApiError(502, { code: "invalid_hermes_profile_response" }); +} + +/** The default profile first, then every named profile; the server owns discovery. */ +export async function loadHermesProfiles(apiBase: string, signal?: AbortSignal): Promise { + const body = await readIntegrationResponse(await fetch(apiBase + "/api/client-integrations/hermes/profiles", { signal })); + if (!isRecord(body) || !Array.isArray(body.profiles)) invalid(); + const profiles = body.profiles.map((row): HermesProfileStatus => { + if (!isRecord(row) || typeof row.hermesProfile !== "string" || typeof row.configPath !== "string" + || typeof row.installed !== "boolean" || !STATES.includes(row.state as IntegrationState)) invalid(); + return { + hermesProfile: row.hermesProfile, configPath: row.configPath, + state: row.state as IntegrationState, installed: row.installed, + ...(typeof row.error === "string" ? { error: row.error } : {}), + }; + }); + if (profiles[0]?.hermesProfile !== "default" || new Set(profiles.map(row => row.hermesProfile)).size !== profiles.length) invalid(); + return profiles; +} diff --git a/gui/src/pages/integrations/integration-api.ts b/gui/src/pages/integrations/integration-api.ts index d72609b29bf..0145651e9c6 100644 --- a/gui/src/pages/integrations/integration-api.ts +++ b/gui/src/pages/integrations/integration-api.ts @@ -93,6 +93,10 @@ export interface IntegrationStatus { /** Aside's explicit account-backed profile scope and desired sync state. */ profileId?: number; enabled?: boolean; + /** The Hermes profile a profile-scoped read describes (#7674). */ + hermesProfile?: string; + /** Why a listed Hermes profile cannot be changed safely. */ + error?: string; raycast?: RaycastInstall; droidReasoning?: DroidReasoningStatus; } @@ -194,6 +198,8 @@ export interface ToggleIntegrationOptions { profileId?: number; binding?: IntegrationPlanBinding; droidReasoningDefaults?: Record; + /** A named Hermes profile; omitted for the default profile. */ + hermesProfile?: string; } export interface RestoreIntegrationOptions { @@ -202,6 +208,7 @@ export interface RestoreIntegrationOptions { signal?: AbortSignal; profileId?: number; binding: IntegrationPlanBinding; + hermesProfile?: string; } export type IntegrationRefusalCode = @@ -462,6 +469,24 @@ function clientPath(client: FileIntegrationClientId, profileId?: number): string return client === "aside" ? "/api/client-integrations/aside/profiles" : `/api/client-integrations/${encodeURIComponent(client)}`; } +/** + * The query that scopes a request to a named Hermes profile. Shared paths also name the client, + * which the server requires before it reads `profile` as a Hermes name rather than an Aside id. + */ +function hermesScope(hermesProfile: string | undefined, sharedPath: boolean): string { + if (hermesProfile === undefined) return ""; + const profile = `profile=${encodeURIComponent(hermesProfile)}`; + return sharedPath ? `client=hermes&${profile}` : profile; +} + +function withQuery(path: string, query: string): string { + return query ? `${path}${path.includes("?") ? "&" : "?"}${query}` : path; +} + +function invalidHermesResponse(): IntegrationApiError { + return new IntegrationApiError(502, { code: "invalid_hermes_profile_response" }); +} + export async function loadIntegrationStates(apiBase: string, signal?: AbortSignal) { return readResponse( await fetch(`${apiBase}/api/client-integrations`, { signal }), @@ -473,10 +498,12 @@ export async function loadIntegrationState( client: FileIntegrationClientId, signal?: AbortSignal, profileId?: number, + hermesProfile?: string, ) { const result = await readResponse( - await fetch(`${apiBase}${clientPath(client, profileId)}`, { signal }), + await fetch(`${apiBase}${withQuery(clientPath(client, profileId), hermesScope(hermesProfile, false))}`, { signal }), ); + if (hermesProfile !== undefined && (client !== "hermes" || result.hermesProfile !== hermesProfile)) throw invalidHermesResponse(); if (profileId !== undefined) { const parsed = parseAsideProfileStatus(result); if (!parsed || parsed.profileId !== profileId) throw new IntegrationApiError(502, { code: "invalid_aside_profile_response" }); @@ -490,10 +517,12 @@ export async function loadIntegrationJournal( client?: FileIntegrationClientId, signal?: AbortSignal, profileId?: number, + hermesProfile?: string, ) { if (profileId !== undefined && client !== "aside") throw new IntegrationApiError(400, { code: "invalid_aside_profile" }); + if (hermesProfile !== undefined && client !== "hermes") throw new IntegrationApiError(400, { code: "invalid_hermes_profile" }); const path = client === "aside" ? `${clientPath(client, profileId)}/journal` - : `/api/client-integrations/journal${client ? `?client=${encodeURIComponent(client)}` : ""}`; + : withQuery(`/api/client-integrations/journal${client ? `?client=${encodeURIComponent(client)}` : ""}`, hermesScope(hermesProfile, false)); const result = await readResponse( await fetch(`${apiBase}${path}`, { signal }), ); @@ -509,8 +538,11 @@ export async function previewIntegrationMutation( signal?: AbortSignal, profileId?: number, droidReasoningDefaults?: Record, + hermesProfile?: string, ) { - const path = profileId === undefined ? "/api/client-integrations/preview" : `${profilePath(profileId)}/preview`; + const path = profileId === undefined + ? withQuery("/api/client-integrations/preview", hermesScope(hermesProfile, true)) + : `${profilePath(profileId)}/preview`; const requestBody = { ...(profileId === undefined ? { clientId: client } : {}), operation, @@ -529,8 +561,11 @@ export async function previewIntegrationRestore( confirmDrift = false, signal?: AbortSignal, profileId?: number, + hermesProfile?: string, ) { - const path = profileId === undefined ? "/api/client-integrations/restore/preview" : `${profilePath(profileId)}/preview`; + const path = profileId === undefined + ? withQuery("/api/client-integrations/restore/preview", hermesScope(hermesProfile, true)) + : `${profilePath(profileId)}/preview`; const body = profileId === undefined ? { opId, confirmDrift } : { operation: "restore", opId, confirmDrift }; const plan = parseIntegrationMutationPlan(await readResponse(await fetch(`${apiBase}${path}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), signal, @@ -540,11 +575,12 @@ export async function previewIntegrationRestore( } export async function toggleIntegration(apiBase: string, client: FileIntegrationClientId, options: ToggleIntegrationOptions) { - const { enabled, signal, overwriteConflict, profileId, binding, droidReasoningDefaults } = options; + const { enabled, signal, overwriteConflict, profileId, binding, droidReasoningDefaults, hermesProfile } = options; + if (hermesProfile !== undefined && client !== "hermes") throw new IntegrationApiError(400, { code: "invalid_hermes_profile" }); const expectedOperation: IntegrationPlanOperation = enabled ? (overwriteConflict ? "overwrite" : "apply") : "disable"; let result: IntegrationToggleResult | { ok: false; message?: string; results?: unknown }; try { - result = await readResponse(await fetch(`${apiBase}${clientPath(client, profileId)}`, { + result = await readResponse(await fetch(`${apiBase}${withQuery(clientPath(client, profileId), hermesScope(hermesProfile, false))}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ enabled, ...(overwriteConflict === true ? { overwriteConflict: true } : {}), ...binding, @@ -574,10 +610,10 @@ export async function restoreIntegration( apiBase: string, options: RestoreIntegrationOptions, ) { - const { opId, confirmDrift = false, signal, profileId, binding } = options; + const { opId, confirmDrift = false, signal, profileId, binding, hermesProfile } = options; let result: IntegrationRestoreResult; try { - result = await readResponse(await fetch(`${apiBase}${profileId === undefined ? "/api/client-integrations/restore" : `${profilePath(profileId)}/restore`}`, { + result = await readResponse(await fetch(`${apiBase}${profileId === undefined ? withQuery("/api/client-integrations/restore", hermesScope(hermesProfile, true)) : `${profilePath(profileId)}/restore`}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ opId, confirmDrift, ...binding }), @@ -604,6 +640,7 @@ export async function deleteJournalEntry( opId: string, signal?: AbortSignal, profileId?: number, + hermesProfile?: string, ) { const result = await readResponse<{ ok: true; @@ -612,7 +649,7 @@ export async function deleteJournalEntry( snapshotRemoved: boolean; profileId?: number; }>( - await fetch(`${apiBase}${profileId === undefined ? "/api/client-integrations/journal" : `${profilePath(profileId)}/journal`}?opId=${encodeURIComponent(opId)}`, { + await fetch(`${apiBase}${withQuery(profileId === undefined ? "/api/client-integrations/journal" : `${profilePath(profileId)}/journal`, hermesScope(hermesProfile, true))}${hermesProfile === undefined ? "?" : "&"}opId=${encodeURIComponent(opId)}`, { method: "DELETE", signal, }), diff --git a/gui/src/styles-integrations.css b/gui/src/styles-integrations.css index 88379636b11..732dbbae860 100644 --- a/gui/src/styles-integrations.css +++ b/gui/src/styles-integrations.css @@ -203,6 +203,7 @@ .aside-profile-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; margin: 16px 0; } .aside-profile-toolbar > .btn { margin-left: auto; } .aside-profile-list { border-top: 1px solid var(--border); } +.hermes-profile-list { list-style: none; margin: 0; padding: 0; } .aside-profile-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 16px; padding: 16px 0; border-bottom: 1px solid var(--border); } .aside-profile-info { min-width: 0; display: grid; gap: 4px; justify-items: start; } .aside-profile-name { padding: 2px 0; min-height: 28px; max-width: 100%; white-space: normal; overflow-wrap: anywhere; text-align: left; } diff --git a/scripts/test-layout/layout-clients.json b/scripts/test-layout/layout-clients.json index ee594824421..34a42e679da 100644 --- a/scripts/test-layout/layout-clients.json +++ b/scripts/test-layout/layout-clients.json @@ -139,6 +139,8 @@ "link-apply-record.test.ts": "clients", "client-runtime-restart.test.ts": "clients", "link-parity-reporter.test.ts": "clients", + "hermes-profiles.test.ts": "clients", + "hermes-profile-routes.test.ts": "clients", "cli-sync-expect-link.test.ts": "cli", "ci-pr-hygiene-approvals.test.ts": "ci-workflows" } diff --git a/src/cli/uninstall-integrations.ts b/src/cli/uninstall-integrations.ts index 5645afee2e2..8f2ea23997e 100644 --- a/src/cli/uninstall-integrations.ts +++ b/src/cli/uninstall-integrations.ts @@ -1,10 +1,12 @@ +import { basename, dirname } from "node:path"; import type { ExportModel } from "../clients/config-export"; import { guardAsideProfileIO, listAsideProfiles } from "../clients/aside-profiles"; import { loadConfig } from "../config"; import { listAsideProfileStores } from "../integrations/aside-profile-context"; +import { hermesProfileBoundary, listHermesProfileStores } from "../integrations/hermes-profiles"; import { isIntegrationClientId, type IntegrationClientId } from "../integrations/registry"; import { createIntegrationStateStore, type IntegrationStateStore } from "../integrations/store"; -import { disableIntegrationCoordinated, type WriteOutcome } from "../integrations/writer"; +import { disableIntegrationCoordinated, type CoordinatedIntegrationOptions, type WriteOutcome } from "../integrations/writer"; import { loadExportModels } from "../server/management/model-rows"; import type { OcxConfig } from "../types"; @@ -12,7 +14,7 @@ export interface UninstallIntegrationCleanupDeps { createStore: () => IntegrationStateStore; loadConfig: () => OcxConfig; loadModels: (config: OcxConfig) => Promise; - disable: (input: Parameters[0]) => Promise; + disable: (input: Parameters[0], options?: CoordinatedIntegrationOptions) => Promise; env?: NodeJS.ProcessEnv; home?: string; } @@ -21,7 +23,7 @@ const defaults: UninstallIntegrationCleanupDeps = { createStore: () => createIntegrationStateStore(), loadConfig, loadModels: config => loadExportModels(config), - disable: input => disableIntegrationCoordinated(input), + disable: (input, options) => disableIntegrationCoordinated(input, options), }; export interface UninstallIntegrationCleanupResult { @@ -46,7 +48,7 @@ export async function cleanupOwnedIntegrationsBeforeUninstall( } } type CleanupTarget = Pick[0], "clientId" | "store" | "io" | "resolvedPaths"> - & { store: IntegrationStateStore; profileId?: number }; + & { store: IntegrationStateStore; profileId?: number; guard?: CoordinatedIntegrationOptions["guard"] }; const targets: CleanupTarget[] = (rawIds as IntegrationClientId[]).sort() .map(clientId => ({ clientId, store })); // Read every child before any mutation. A broken child must not look like no ownership. @@ -57,6 +59,38 @@ export async function cleanupOwnedIntegrationsBeforeUninstall( } if (childRecords.aside) targets.push({ clientId: "aside", ...child }); } + // Named Hermes profiles keep their own stores (#7674). The record names the file we wrote; it + // must be that profile's config.yaml, so a record cannot aim this disable at any other file. + for (const child of listHermesProfileStores(store)) { + const childRecords = child.store.readRecordsStrict(); + if (Object.keys(childRecords).some(id => id !== "hermes")) { + throw new Error("integration cleanup refused: Hermes profile storage names another client"); + } + const configPath = childRecords.hermes?.configPath; + if (configPath === undefined) continue; + const profileHome = dirname(configPath); + if (basename(configPath) !== "config.yaml" || basename(profileHome) !== child.name + || basename(dirname(profileHome)) !== "profiles") { + throw new Error("integration cleanup refused: Hermes profile ownership is mismatched"); + } + // Pinned now, before any await, and checked again inside the disable itself: a profile or + // `profiles` directory swapped for a link while models load cannot redirect this cleanup. + let holds: () => void; + try { holds = hermesProfileBoundary({ name: child.name, home: profileHome, configPath }, child.store); } + catch (error) { + throw new Error(`integration cleanup refused for Hermes profile ${child.name}: ${error instanceof Error ? error.message : String(error)}`); + } + targets.push({ + clientId: "hermes", store: child.store, resolvedPaths: { configPath, detectDir: profileHome }, + guard: () => { + try { holds(); return null; } + catch (error) { + return { ok: false, reason: "unsafe", state: "unsafe", clientId: "hermes", + message: error instanceof Error ? error.message : "Hermes profile cannot be changed safely" }; + } + }, + }); + } if (targets.length === 0) return { attempted: 0, changed: 0 }; const asideTargets = targets.filter(target => target.clientId === "aside"); @@ -85,7 +119,7 @@ export async function cleanupOwnedIntegrationsBeforeUninstall( const config = deps.loadConfig(); const models = await deps.loadModels(config); let changed = 0; - for (const { clientId, store: targetStore, io, resolvedPaths } of targets) { + for (const { clientId, store: targetStore, io, resolvedPaths, guard } of targets) { const result = await deps.disable({ clientId, models, @@ -96,7 +130,7 @@ export async function cleanupOwnedIntegrationsBeforeUninstall( ...(resolvedPaths ? { resolvedPaths } : {}), ...(deps.env ? { env: deps.env } : {}), ...(deps.home ? { home: deps.home } : {}), - }); + }, guard ? { guard } : undefined); if (!result.ok) { throw new Error(`integration cleanup refused for ${clientId}: ${result.message}` + (result.residual ? "; recovery did not complete; inspect the client file and retained snapshots before retrying" : "")); @@ -107,7 +141,7 @@ export async function cleanupOwnedIntegrationsBeforeUninstall( if (result.changed) changed++; } // A profile can gain ownership while catalog loading or an earlier disable awaited its lock. - if ([store, ...listAsideProfileStores(store).map(child => child.store)] + if ([store, ...listAsideProfileStores(store).map(child => child.store), ...listHermesProfileStores(store).map(child => child.store)] .some(current => Object.keys(current.readRecordsStrict()).length > 0)) { throw new Error("integration cleanup refused: ownership changed before removal"); } diff --git a/src/integrations/hermes-profiles.ts b/src/integrations/hermes-profiles.ts new file mode 100644 index 00000000000..e4bda8d2efb --- /dev/null +++ b/src/integrations/hermes-profiles.ts @@ -0,0 +1,251 @@ +/** + * Hermes profiles: where each Hermes bot keeps its own config.yaml. + * + * Hermes gives every profile its own home. The default profile is the home + * OpenCodex has always written (`HERMES_HOME`, else `%LOCALAPPDATA%\hermes` on + * Windows, else `~/.hermes`); a named profile is `/profiles//`, and + * Hermes selects it by running with `HERMES_HOME` pointed at that directory. + * + * The rules below mirror Hermes itself (NousResearch/hermes-agent + * `hermes_constants.py`: `PROFILE_ID_RE`, `_PROFILE_IDENTITY_MARKERS`, + * `profile_tombstone_path`, `_hermes_root_of`), so this lists exactly the + * profiles `hermes profile list` shows and never a cache or log directory that + * happens to sit under `profiles/`. + * + * Each named profile keeps its own ownership store under + * `/hermes-profiles//`, so its records, journal and snapshots + * can never be confused with the default profile's. + */ +import { lstatSync, readdirSync, type Stats } from "node:fs"; +import { homedir } from "node:os"; +import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { hermesHomeDir } from "../clients/config-export"; +import { createIntegrationStateStore, type IntegrationStateStore } from "./store"; +import type { ResolvedIntegrationPaths } from "./registry"; + +export const HERMES_DEFAULT_PROFILE = "default"; +/** Hermes `PROFILE_ID_RE`: the only names Hermes itself creates or lists. */ +export const HERMES_PROFILE_ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/; +/** Hermes `_PROFILE_IDENTITY_MARKERS`: a directory without one is not a profile. */ +const IDENTITY_MARKERS = ["config.yaml", ".env", "SOUL.md", "profile.yaml", "auth.json", "state.db"] as const; +const TOMBSTONE_DIR = ".deleted"; +export const HERMES_PROFILE_STORE_DIR = "hermes-profiles"; + +export interface HermesProfile { + /** `default`, or the directory name under `/profiles`. */ + name: string; + /** The HERMES_HOME Hermes runs this profile with. */ + home: string; + configPath: string; +} + +export class HermesProfileError extends Error { + constructor(readonly code: string, readonly status: 400 | 404 | 409, message: string) { + super(message); + this.name = "HermesProfileError"; + } +} + +function lstatOrNull(path: string): Stats | null { + try { return lstatSync(path); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT" || (error as NodeJS.ErrnoException).code === "ENOTDIR") return null; + throw new HermesProfileError("hermes_profiles_unavailable", 409, "Hermes profiles cannot be read"); + } +} + +function isWithin(base: string, target: string): boolean { + const rel = relative(resolve(base), resolve(target)); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); +} + +function samePath(a: string, b: string): boolean { + return relative(resolve(a), resolve(b)) === ""; +} + +/** + * The Hermes root that holds `profiles/`, as Hermes `_hermes_root_of` derives it: + * no override, or an override inside the platform home, means the platform home; + * `/profiles/` means ``; any other override is its own root. + */ +export function hermesRootDir(env: NodeJS.ProcessEnv = process.env, home: string = homedir()): string { + const { HERMES_HOME: _ignored, ...withoutOverride } = env; + const native = hermesHomeDir(withoutOverride, home); + const override = env.HERMES_HOME?.trim(); + if (!override) return native; + if (isWithin(native, override)) return native; + return basename(dirname(override)) === "profiles" ? dirname(dirname(override)) : override; +} + +function profileOf(name: string, profileHome: string): HermesProfile { + return { name, home: profileHome, configPath: join(profileHome, "config.yaml") }; +} + +/** + * The default profile first, then every live named profile, sorted by name. + * + * A named profile must be a real directory: a symbolic link or junction under + * `profiles/` is skipped, because writing through it would edit a file outside + * the Hermes root. A profile whose home IS the default home (HERMES_HOME pointed + * at a profile) is listed once, as the default. + */ +export function listHermesProfiles(env: NodeJS.ProcessEnv = process.env, home: string = homedir()): HermesProfile[] { + const defaultHome = hermesHomeDir(env, home); + const profiles = [profileOf(HERMES_DEFAULT_PROFILE, defaultHome)]; + const profilesDir = join(hermesRootDir(env, home), "profiles"); + const rootStat = lstatOrNull(profilesDir); + if (!rootStat?.isDirectory()) return profiles; + let names: string[]; + try { names = readdirSync(profilesDir); } + catch { throw new HermesProfileError("hermes_profiles_unavailable", 409, "Hermes profiles cannot be read"); } + for (const name of names.sort()) { + if (name === HERMES_DEFAULT_PROFILE || !HERMES_PROFILE_ID_RE.test(name)) continue; + const profileHome = join(profilesDir, name); + const stat = lstatOrNull(profileHome); + if (!stat?.isDirectory() || stat.isSymbolicLink()) continue; + const hasIdentity = IDENTITY_MARKERS.some(marker => { + const markerStat = lstatOrNull(join(profileHome, marker)); + return markerStat !== null && (markerStat.isFile() || markerStat.isSymbolicLink()); + }); + if (!hasIdentity || lstatOrNull(join(profilesDir, TOMBSTONE_DIR, name)) !== null) continue; + if (samePath(profileHome, defaultHome)) continue; + profiles.push(profileOf(name, profileHome)); + } + return profiles; +} + +/** An operator-selected profile, or a refusal that names why. */ +export function resolveHermesProfile( + name: string, + env: NodeJS.ProcessEnv = process.env, + home: string = homedir(), +): HermesProfile { + // The default profile is the home OpenCodex has always used; it never depends on its siblings. + if (name === HERMES_DEFAULT_PROFILE) return profileOf(HERMES_DEFAULT_PROFILE, hermesHomeDir(env, home)); + if (!HERMES_PROFILE_ID_RE.test(name)) { + throw new HermesProfileError("invalid_hermes_profile", 400, "Hermes profile names use lowercase letters, digits, '-' and '_'"); + } + const profile = listHermesProfiles(env, home).find(candidate => candidate.name === name); + if (!profile) throw new HermesProfileError("hermes_profile_not_found", 404, "Hermes profile is not present"); + return profile; +} + +/** The environment Hermes runs this profile with. The default profile changes nothing. */ +export function hermesProfileEnv(profile: HermesProfile, env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + return profile.name === HERMES_DEFAULT_PROFILE ? env : { ...env, HERMES_HOME: profile.home }; +} + +export function hermesProfilePaths(profile: HermesProfile): ResolvedIntegrationPaths { + return { configPath: profile.configPath, detectDir: profile.home }; +} + +function unsafe(message: string): never { + throw new HermesProfileError("hermes_profile_unsafe", 409, message); +} + +/** `dev:ino` of a real directory, null when it does not exist; a link or junction is refused. */ +function realDirectoryKey(path: string): string | null { + const stat = lstatOrNull(path); + if (stat === null) return null; + if (stat.isSymbolicLink() || !stat.isDirectory()) unsafe("A Hermes profile path is a link or not a directory"); + return `${stat.dev}:${stat.ino}`; +} + +/** + * Pin a named profile's directories and owner, and return the check that proves + * they still hold. + * + * Discovery happens before the model roster loads, and the write happens after. + * The check runs again immediately before the write: the `profiles` directory and + * the profile home must be the same real directories (never a link or junction + * swapped in meanwhile), the profile must not have been deleted, the ownership + * store must not be a link, and an existing ownership record must name this + * profile's config.yaml. The last rule matters when HERMES_HOME moves: a profile + * of the same name under another Hermes root shares the store name, and must not + * inherit or overwrite the original profile's ownership. + */ +export function hermesProfileBoundary(profile: HermesProfile, store: IntegrationStateStore): () => void { + const profilesDir = dirname(profile.home); + const pinned = [profilesDir, profile.home].map(path => ({ path, key: realDirectoryKey(path) })); + if (pinned.some(entry => entry.key === null)) { + throw new HermesProfileError("hermes_profile_not_found", 404, "Hermes profile is not present"); + } + const check = () => { + for (const { path, key } of pinned) { + if (realDirectoryKey(path) !== key) unsafe("The Hermes profile directory changed while this change was prepared"); + } + if (lstatOrNull(join(profilesDir, TOMBSTONE_DIR, profile.name)) !== null) { + throw new HermesProfileError("hermes_profile_not_found", 404, "Hermes profile was deleted"); + } + realDirectoryKey(dirname(store.root)); + realDirectoryKey(store.root); + const owner = store.readRecords().hermes; + if (owner && owner.configPath !== profile.configPath) { + throw new HermesProfileError("hermes_profile_owner_mismatch", 409, + "This profile's OpenCodex ownership names a Hermes file in another Hermes home; disconnect it there first"); + } + }; + check(); + return check; +} + +/** + * The store a request uses for a named profile: every method first proves the pinned boundary + * still holds, synchronously, at the moment it touches the store. A read that retries snapshot + * maintenance, a journal tombstone, a prune and a writer commit are all covered the same way, so no + * await between scope creation and use can leave a swapped directory unchecked. + */ +export function guardedHermesProfileStore(store: IntegrationStateStore, holds: () => void): IntegrationStateStore { + const guarded: IntegrationStateStore = new Proxy(store, { + get(target, property, receiver) { + const value: unknown = Reflect.get(target, property, receiver); + if (typeof value !== "function") return value; + // io() closes over the raw store; route its bookkeeping back through this facade. + if (property === "io") { + return () => { + holds(); + return { + ...target.io(), + appendJournal: (entry: Parameters[0]) => guarded.appendJournal(entry), + putRecord: (record: Parameters[0]) => guarded.putRecord(record), + dropRecord: (clientId: Parameters[0]) => guarded.dropRecord(clientId), + }; + }; + } + return (...args: unknown[]) => { + holds(); + return Reflect.apply(value, target, args); + }; + }, + }); + return guarded; +} + +/** A named profile's ownership store; the default profile keeps the root store. */ +export function hermesProfileStore(root: IntegrationStateStore, name: string): IntegrationStateStore { + if (name === HERMES_DEFAULT_PROFILE) return root; + if (!HERMES_PROFILE_ID_RE.test(name)) { + throw new HermesProfileError("invalid_hermes_profile", 400, "Hermes profile names use lowercase letters, digits, '-' and '_'"); + } + return createIntegrationStateStore(join(root.root, HERMES_PROFILE_STORE_DIR, name)); +} + +/** + * Every persisted named-profile store, whether or not the profile still exists. + * Uninstall needs the orphans too: a deleted profile's record is still proof of + * what we wrote. + */ +export function listHermesProfileStores(root: IntegrationStateStore): Array<{ name: string; store: IntegrationStateStore }> { + const directory = join(root.root, HERMES_PROFILE_STORE_DIR); + if (realDirectoryKey(directory) === null) return []; + let names: string[]; + try { names = readdirSync(directory); } + catch { throw new HermesProfileError("hermes_profile_store_unsafe", 409, "Hermes profile ownership storage cannot be read"); } + return names.sort().map(name => { + if (name === HERMES_DEFAULT_PROFILE || !HERMES_PROFILE_ID_RE.test(name)) { + throw new HermesProfileError("hermes_profile_store_unsafe", 409, "Hermes profile ownership storage names an invalid profile"); + } + realDirectoryKey(join(directory, name)); + return { name, store: createIntegrationStateStore(join(directory, name)) }; + }); +} diff --git a/src/integrations/writer.ts b/src/integrations/writer.ts index 8989c665586..0f92000053f 100644 --- a/src/integrations/writer.ts +++ b/src/integrations/writer.ts @@ -829,6 +829,12 @@ export interface CoordinatedIntegrationOptions { * `revalidate` and any lock acquisition). A non-null result refuses without writing. */ guard?: (frozen: IntegrationWriteInput) => WriteOutcome | null; + /** + * Restore's own synchronous last word, evaluated immediately before the restore transaction + * with no await in between. Restore deliberately ignores `guard` (the background-refresh + * admission hook), so a caller that must re-prove a boundary at restore time uses this one. + */ + restoreGuard?: (frozen: IntegrationWriteInput) => WriteOutcome | null; } /** Freeze all mutable resolution seams before the first lock await. */ @@ -1026,7 +1032,9 @@ export async function restoreIntegrationCoordinated( if (!prepared.ok) return prepared.refusal; const frozen = prepared.value; const spec = INTEGRATION_CLIENTS[frozen.clientId]; - const run = () => restoreIntegration({ ...frozen, opId: input.opId, confirmDrift: input.confirmDrift }); + // Nothing awaits between restoreGuard and the restore transaction. + const run = () => options?.restoreGuard?.(frozen) + ?? restoreIntegration({ ...frozen, opId: input.opId, confirmDrift: input.confirmDrift }); if (!spec.writerLock) { const refused = await options?.revalidate?.(frozen); return refused ?? run(); diff --git a/src/server/management/hermes-profile-routes.ts b/src/server/management/hermes-profile-routes.ts new file mode 100644 index 00000000000..403fa5f76f4 --- /dev/null +++ b/src/server/management/hermes-profile-routes.ts @@ -0,0 +1,156 @@ +/** + * Hermes profile selection for the client-integration routes (#7674). + * + * A Hermes profile is the same integration in a different home, so this module + * adds no mutation of its own. It turns a `profile` selector into a request + * scope (that profile's ownership store and the HERMES_HOME Hermes runs it with) + * and hands the request back to the generic routes, which then read, plan, write, + * journal and restore exactly as they do for the default profile. + * + * Accepted spellings, all with `profile=`: + * - `/api/client-integrations/hermes` (GET, PUT) + * - `/api/client-integrations/{journal,preview,restore,restore/preview}` with `client=hermes` + * + * The `profile` selector is removed before the request continues (the Aside + * handlers treat any `profile` as an Aside account id, and a Hermes name such as + * `123` is also a valid one), and the generic routes skip every Aside delegation + * for a scoped request, `profile=default` included. A Hermes-scoped request can + * therefore never reach an Aside operation, and it sees only Hermes rows. + */ +import { + HERMES_DEFAULT_PROFILE, + HermesProfileError, + guardedHermesProfileStore, + hermesProfileBoundary, + hermesProfileEnv, + hermesProfilePaths, + hermesProfileStore, + listHermesProfiles, + resolveHermesProfile, + type HermesProfile, +} from "../../integrations/hermes-profiles"; +import type { ResolvedIntegrationPaths } from "../../integrations/registry"; +import type { IntegrationStateStore } from "../../integrations/store"; +import type { ExportModel } from "../../clients/config-export"; +import { jsonResponse } from "../auth-cors"; +import type { ManagementContext } from "./context"; + +export interface HermesRequestScope { + profile: HermesProfile; + store: IntegrationStateStore; + env: NodeJS.ProcessEnv; + /** Named profiles only; the default profile resolves its paths as it always has. */ + resolvedPaths?: ResolvedIntegrationPaths; + /** Throws a HermesProfileError when the pinned profile no longer holds. */ + assertBoundary: () => void; +} + +export interface HermesScopeBase { + store: IntegrationStateStore; + env?: NodeJS.ProcessEnv; + home?: string; +} + +export const HERMES_PROFILES_PATH = "/api/client-integrations/hermes/profiles"; +const HERMES_CLIENT_PATH = "/api/client-integrations/hermes"; +const HISTORY_PATHS = [ + "/api/client-integrations/journal", + "/api/client-integrations/preview", + "/api/client-integrations/restore", + "/api/client-integrations/restore/preview", +]; + +/* + * Keyed by the Request object, which every copy of the management context shares. + * Entries die with the request. + */ +const scopes = new WeakMap(); + +export function hermesRequestScope(req: Request): HermesRequestScope | undefined { + return scopes.get(req); +} + +function scopeFor(profile: HermesProfile, base: HermesScopeBase): HermesRequestScope { + const env = base.env ?? process.env; + if (profile.name === HERMES_DEFAULT_PROFILE) { + return { profile, store: base.store, env, assertBoundary: () => {} }; + } + const store = hermesProfileStore(base.store, profile.name); + const assertBoundary = hermesProfileBoundary(profile, store); + return { + profile, store: guardedHermesProfileStore(store, assertBoundary), env: hermesProfileEnv(profile, env), + resolvedPaths: hermesProfilePaths(profile), assertBoundary, + }; +} + +function refusal(ctx: ManagementContext, error: HermesProfileError): Response { + return jsonResponse({ error: error.message, code: error.code, clientId: "hermes" }, error.status, ctx.req, ctx.config); +} + +/** + * The context to continue with, a refusal, or null when the request names no + * Hermes profile. `profile=default` keeps the root store and the default paths. + */ +export function scopeHermesProfileRequest(ctx: ManagementContext, base: HermesScopeBase): ManagementContext | Response | null { + const { url, req } = ctx; + if (!url.searchParams.has("profile")) return null; + const onClientPath = url.pathname === HERMES_CLIENT_PATH; + const onHistoryPath = HISTORY_PATHS.includes(url.pathname) && url.searchParams.get("client") === "hermes"; + if (!onClientPath && !onHistoryPath) return null; + const selectors = url.searchParams.getAll("profile"); + if (selectors.length !== 1) { + return refusal(ctx, new HermesProfileError("invalid_hermes_profile", 400, "Name exactly one Hermes profile")); + } + let scope: HermesRequestScope; + try { scope = scopeFor(resolveHermesProfile(selectors[0]!, base.env ?? process.env, base.home), base); } + catch (error) { + if (error instanceof HermesProfileError) return refusal(ctx, error); + throw error; + } + const next = new URL(url); + next.searchParams.delete("profile"); + scopes.set(req, scope); + return { ...ctx, url: next }; +} + +/** + * GET /api/client-integrations/hermes/profiles: every profile with its own state. + * + * The roster loads first. Each profile's boundary is then checked and its state read with no await + * in between, and a profile whose boundary does not hold is described from its path alone: reading + * state also retries snapshot maintenance, which must never run through a store that was refused. + */ +export async function hermesProfileListResponse( + ctx: ManagementContext, + base: HermesScopeBase, + loadModels: () => Promise, + read: (scope: HermesRequestScope | null, models: readonly ExportModel[]) => object, +): Promise { + if (ctx.url.pathname !== HERMES_PROFILES_PATH || ctx.req.method !== "GET") return null; + if ([...ctx.url.searchParams.keys()].length > 0) { + return refusal(ctx, new HermesProfileError("invalid_hermes_profile", 400, "The profile list takes no selectors")); + } + try { + const models = await loadModels(); + const profiles = listHermesProfiles(base.env ?? process.env, base.home); + const rows = profiles.map(profile => { + let scope: HermesRequestScope; + try { scope = scopeFor(profile, base); } + catch (error) { + if (!(error instanceof HermesProfileError)) throw error; + return { + clientId: "hermes", configPath: profile.configPath, state: "unsafe", installed: true, + snapshotCount: -1, retentionDegraded: true, hermesProfile: profile.name, error: error.message, + }; + } + return { ...read(scope.resolvedPaths ? scope : null, models), hermesProfile: profile.name }; + }); + return jsonResponse({ profiles: rows }, 200, ctx.req, ctx.config); + } catch (error) { + if (error instanceof HermesProfileError) return refusal(ctx, error); + return jsonResponse({ + error: error instanceof Error ? error.message : String(error), + code: "integration_internal_error", + }, 500, ctx.req, ctx.config); + } +} diff --git a/src/server/management/integration-routes.ts b/src/server/management/integration-routes.ts index 02908a8ca62..ea5f9c3be00 100644 --- a/src/server/management/integration-routes.ts +++ b/src/server/management/integration-routes.ts @@ -15,6 +15,8 @@ import { handleAsideProfileRoutes, asideJournalResponse, asideRestoreResponse, asideJournalDeleteResponse, type AsideProfileRouteOptions, } from "./aside-profile-routes"; +import { hermesProfileListResponse, hermesRequestScope, scopeHermesProfileRequest } from "./hermes-profile-routes"; +import { HermesProfileError } from "../../integrations/hermes-profiles"; import type { IntegrationIO } from "../../integrations/config-io"; import { createClineIO } from "../../integrations/cline-io"; import { matchesOperationResult } from "../../integrations/journal"; @@ -79,6 +81,8 @@ export type IntegrationStateEnvelope = { models: Array<{ model: string; label: string; efforts: string[] }>; defaults: Record; }; + /** The Hermes profile a profile-scoped read describes (#7674). */ + hermesProfile?: string; } & IntegrationStateRecord; export interface IntegrationStateListEnvelope { @@ -181,6 +185,37 @@ function pathOverrides(): { env?: NodeJS.ProcessEnv; home?: string } { }; } +/** A selected Hermes profile's environment and explicit target; nothing for any other request. */ +function scopedPathOverrides(ctx: ManagementContext, clientId: IntegrationClientId): Pick { + const scope = clientId === "hermes" ? hermesRequestScope(ctx.req) : undefined; + return scope?.resolvedPaths ? { ...pathOverrides(), env: scope.env, resolvedPaths: { ...scope.resolvedPaths } } : pathOverrides(); +} + +/** Flight keys carry the profile, so a second profile is busy instead of joining the first one's write. */ +function scopedFlightKey(ctx: ManagementContext, key: string): string { + const scope = hermesRequestScope(ctx.req); + return scope?.resolvedPaths ? `hermes-profile:${scope.profile.name}:${key}` : key; +} + +/** A Hermes-scoped request may only ever see Hermes operations. */ +function outsideHermesScope(ctx: ManagementContext, clientId: IntegrationClientId): boolean { + return hermesRequestScope(ctx.req) !== undefined && clientId !== "hermes"; +} + +/** The selected profile's boundary, as a writer refusal; null when it holds or nothing is pinned. */ +function hermesBoundaryRefusal(ctx: ManagementContext): WriteRefused | null { + const scope = hermesRequestScope(ctx.req); + try { + scope?.assertBoundary(); + return null; + } catch (error) { + return { + ok: false, reason: "unsafe", state: "unsafe", clientId: "hermes", + message: error instanceof Error ? error.message : "Hermes profile cannot be changed safely", + }; + } +} + function isPlainRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -221,8 +256,10 @@ export function setIntegrationMutationFlightTestHooks( * while handing the writer a separate default store, so a test could not bind * the whole operation to a temp root (A-gate round 12). */ -function integrationStore(): IntegrationStateStore { - return integrationMutationTestHooks?.store ?? createIntegrationStateStore(); +function integrationStore(ctx?: ManagementContext): IntegrationStateStore { + // A selected Hermes profile owns its own store; every other request keeps the root. + return (ctx ? hermesRequestScope(ctx.req)?.store : undefined) + ?? integrationMutationTestHooks?.store ?? createIntegrationStateStore(); } function asideOptions(ctx: ManagementContext): AsideProfileRouteOptions { @@ -256,7 +293,7 @@ async function buildIntegrationWriteInput( ...(droidReasoningDefaults === undefined ? {} : { droidReasoningDefaults: { ...droidReasoningDefaults } }), store, io: integrationMutationTestHooks?.io, - ...pathOverrides(), + ...scopedPathOverrides(ctx, clientId), }; } @@ -288,7 +325,7 @@ async function buildIntegrationPreviewInput( ...(droidReasoningDefaults === undefined ? {} : { droidReasoningDefaults: { ...droidReasoningDefaults } }), store, io: integrationMutationTestHooks?.io, - ...pathOverrides(), + ...scopedPathOverrides(ctx, clientId), }, }; } @@ -328,7 +365,7 @@ function restoreClientScopeResponse(ctx: ManagementContext, opId: string, expect if (expectedClientId === undefined) return null; if (typeof expectedClientId !== "string" || !isIntegrationClientId(expectedClientId)) return invalidClientResponse(ctx); try { - const operation = integrationStore().findOperation(opId); + const operation = integrationStore(ctx).findOperation(opId); if (!operation) return jsonResponse({ error: "integration operation not found", code: "integration_operation_not_found", opId }, 404, ctx.req, ctx.config); if (operation.clientId !== expectedClientId) return jsonResponse({ error: "integration operation belongs to a different client", code: "integration_client_mismatch" }, 409, ctx.req, ctx.config); return null; @@ -455,6 +492,10 @@ function stalePlanGuard( } function internalErrorResponse(error: unknown, ctx: ManagementContext): Response { + // A selected Hermes profile whose boundary stopped holding is a refusal, not a server fault. + if (error instanceof HermesProfileError) { + return jsonResponse({ error: error.message, code: error.code, clientId: "hermes" }, error.status, ctx.req, ctx.config); + } return jsonResponse({ error: error instanceof Error ? error.message : String(error), code: "integration_internal_error", @@ -563,12 +604,14 @@ async function handleJournalDelete(ctx: ManagementContext): Promise { code: "invalid_op_id", }, 400, req, ctx.config); } - const aside = await asideJournalDeleteResponse(ctx, opId, asideOptions(ctx)); - if (aside) return aside; + if (!hermesRequestScope(ctx.req)) { + const aside = await asideJournalDeleteResponse(ctx, opId, asideOptions(ctx)); + if (aside) return aside; + } try { - const store = integrationStore(); + const store = integrationStore(ctx); const operation = store.findOperation(opId); - if (!operation) { + if (!operation || outsideHermesScope(ctx, operation.clientId)) { // Already retired, or never existed. Both are 404: the tombstone hides // the row from findOperation, so a double-click is idempotent here // rather than a second deletion of something. @@ -625,6 +668,17 @@ async function handleJournalDelete(ctx: ManagementContext): Promise { } export async function handleIntegrationRoutes(ctx: ManagementContext): Promise { + const hermesBase = { store: integrationStore(), ...pathOverrides() }; + const hermesProfiles = await hermesProfileListResponse(ctx, hermesBase, () => loadExportModels(ctx.config), + (scope, models) => readIntegrationState({ + clientId: "hermes", models, config: ctx.config, + port: managementInferencePort(ctx), store: scope?.store ?? hermesBase.store, ...pathOverrides(), + ...(scope?.resolvedPaths ? { env: scope.env, resolvedPaths: { ...scope.resolvedPaths } } : {}), + })); + if (hermesProfiles) return hermesProfiles; + const hermesScoped = scopeHermesProfileRequest(ctx, hermesBase); + if (hermesScoped instanceof Response) return hermesScoped; + if (hermesScoped) ctx = hermesScoped; const { req, url } = ctx; const profileOptions = asideOptions(ctx); const aside = await handleAsideProfileRoutes(ctx, profileOptions); @@ -673,10 +727,10 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise(); for (const operation of storedOperations) { @@ -770,6 +824,9 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise restoreIntegrationCoordinated(restoreInput, { lockSeams: integrationMutationTestHooks?.lockSeams, ...(restoreGuard ? { revalidate: restoreGuard.revalidate } : {}), + restoreGuard: () => hermesBoundaryRefusal(ctx), }), ); const restoreStale = restoreGuard?.response(); @@ -967,8 +1026,9 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise { const options = { lockSeams: integrationMutationTestHooks?.lockSeams, ...(guard ? { revalidate: guard.revalidate } : {}), + guard: () => hermesBoundaryRefusal(ctx), }; if (!parsed.enabled) return disableIntegrationCoordinated(input, options); return parsed.overwriteConflict === true diff --git a/structure/INDEX.md b/structure/INDEX.md index 82e2b7e737c..78f111d9ca4 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -121,6 +121,7 @@ The dashboard, the management API, and third-party client config ownership. | [`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md) | Experimental macOS app-server stdout shim, opt-in launch, restore and failure boundaries. | | [`clients/claude-desktop.md`](clients/claude-desktop.md) | Claude Desktop profile ownership and config-library resolution. | | [`clients/claude-desktop-config-reads.md`](clients/claude-desktop-config-reads.md) | Verified regular-file reads of Claude Desktop config-library metadata, profiles and snapshots. | +| [`clients/hermes.md`](clients/hermes.md) | Hermes profile discovery, per-profile selection, ownership stores, and boundaries. | | [`clients/qoder.md`](clients/qoder.md) | Qoder settings.json provider and custom-model ownership, export and loopback placeholder rules. | | [`companion.md`](companion.md) | Shared timeline filtering, usage/quotas, native and web tray title, and WidgetKit display contracts. | | [`codex-account-controls.md`](codex-account-controls.md) | Account selection order, custom usage thresholds, and stable account-card editing. | @@ -192,7 +193,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/chatgpt/` | [`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md) | | `src/claude/` | [`runtime.md`](runtime.md)
[`providers/anthropic-progress.md`](providers/anthropic-progress.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`clients/claude-desktop-config-reads.md`](clients/claude-desktop-config-reads.md) | | `src/cli.ts` | [`runtime.md`](runtime.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | -| `src/cli/` | [`runtime.md`](runtime.md)
[`config.md`](config.md)
[`remote-messaging.md`](remote-messaging.md)
[`local-messaging.md`](local-messaging.md)
[`cli-management.md`](cli-management.md)
[`provider-completion-diagnostic.md`](provider-completion-diagnostic.md)
[`fallback-policy.md`](fallback-policy.md)
[`clients/integrations.md`](clients/integrations.md)
[`clients/codex-desktop.md`](clients/codex-desktop.md)
[`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | +| `src/cli/` | [`runtime.md`](runtime.md)
[`config.md`](config.md)
[`remote-messaging.md`](remote-messaging.md)
[`local-messaging.md`](local-messaging.md)
[`cli-management.md`](cli-management.md)
[`provider-completion-diagnostic.md`](provider-completion-diagnostic.md)
[`fallback-policy.md`](fallback-policy.md)
[`clients/integrations.md`](clients/integrations.md)
[`clients/codex-desktop.md`](clients/codex-desktop.md)
[`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`clients/hermes.md`](clients/hermes.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `src/cli/account-preservation.ts` | [`providers/openai-signed-in-preservation.md`](providers/openai-signed-in-preservation.md) | | `src/cli/codex-provider.ts` | [`codex-provider-routing.md`](codex-provider-routing.md) | | `src/cli/link-apply-restart.ts` | [`remote-link.md`](remote-link.md) | @@ -235,7 +236,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/grok/` | [`runtime.md`](runtime.md) | | `src/images/` | [`runtime.md`](runtime.md)
[`transports/inventory.md`](transports/inventory.md)
[`providers/anthropic-progress.md`](providers/anthropic-progress.md) | | `src/index.ts` | [`runtime.md`](runtime.md) | -| `src/integrations/` | [`clients/integrations.md`](clients/integrations.md)
[`clients/qoder.md`](clients/qoder.md) | +| `src/integrations/` | [`clients/integrations.md`](clients/integrations.md)
[`clients/hermes.md`](clients/hermes.md)
[`clients/qoder.md`](clients/qoder.md) | | `src/lab/` | [`runtime.md`](runtime.md)
[`adapters/compatibility-lab.md`](adapters/compatibility-lab.md) | | `src/lib/` | [`overview.md`](overview.md)
[`runtime.md`](runtime.md)
[`transports/byte-accounting.md`](transports/byte-accounting.md)
[`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md)
[`transports/responses-failover.md`](transports/responses-failover.md)
[`transports/responses-spend.md`](transports/responses-spend.md)
[`transports/inventory.md`](transports/inventory.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`clients/integrations.md`](clients/integrations.md)
[`ops/service-and-sidecars.md`](ops/service-and-sidecars.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `src/lib/account-auto-pause-response.ts` | [`account-auto-pause.md`](account-auto-pause.md) | @@ -286,7 +287,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/router.ts` | [`runtime.md`](runtime.md) | | `src/routing/` | [`catalog.md`](catalog.md)
[`fallback-policy.md`](fallback-policy.md) | | `src/routing/history/` | [`dashboard-and-usage.md`](dashboard-and-usage.md) | -| `src/server/` | [`providers/generic-account-pool.md`](providers/generic-account-pool.md)
[`runtime.md`](runtime.md)
[`codex-prompts.md`](codex-prompts.md)
[`catalog.md`](catalog.md)
[`subagents.md`](subagents.md)
[`transports/byte-accounting.md`](transports/byte-accounting.md)
[`transports/responses.md`](transports/responses.md)
[`transports/native-reasoning-retention.md`](transports/native-reasoning-retention.md)
[`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md)
[`transports/responses-failover.md`](transports/responses-failover.md)
[`transports/responses-combo-boundaries.md`](transports/responses-combo-boundaries.md)
[`transports/policy-fallback.md`](transports/policy-fallback.md)
[`transports/streaming-health.md`](transports/streaming-health.md)
[`transports/inventory.md`](transports/inventory.md)
[`data-planes/images.md`](data-planes/images.md)
[`data-planes/inbound-compat.md`](data-planes/inbound-compat.md)
[`providers/anthropic-progress.md`](providers/anthropic-progress.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/jev-decision.md`](providers/jev-decision.md)
[`providers/xai-grok.md`](providers/xai-grok.md)
[`adapters/registry.md`](adapters/registry.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`provider-completion-diagnostic.md`](provider-completion-diagnostic.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`fallback-policy.md`](fallback-policy.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | +| `src/server/` | [`providers/generic-account-pool.md`](providers/generic-account-pool.md)
[`runtime.md`](runtime.md)
[`codex-prompts.md`](codex-prompts.md)
[`catalog.md`](catalog.md)
[`subagents.md`](subagents.md)
[`transports/byte-accounting.md`](transports/byte-accounting.md)
[`transports/responses.md`](transports/responses.md)
[`transports/native-reasoning-retention.md`](transports/native-reasoning-retention.md)
[`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md)
[`transports/responses-failover.md`](transports/responses-failover.md)
[`transports/responses-combo-boundaries.md`](transports/responses-combo-boundaries.md)
[`transports/policy-fallback.md`](transports/policy-fallback.md)
[`transports/streaming-health.md`](transports/streaming-health.md)
[`transports/inventory.md`](transports/inventory.md)
[`data-planes/images.md`](data-planes/images.md)
[`data-planes/inbound-compat.md`](data-planes/inbound-compat.md)
[`providers/anthropic-progress.md`](providers/anthropic-progress.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/jev-decision.md`](providers/jev-decision.md)
[`providers/xai-grok.md`](providers/xai-grok.md)
[`adapters/registry.md`](adapters/registry.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`provider-completion-diagnostic.md`](provider-completion-diagnostic.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`fallback-policy.md`](fallback-policy.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`clients/hermes.md`](clients/hermes.md)
[`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | | `src/server/gui-pair-delivery.ts` | [`remote-link.md`](remote-link.md) | | `src/server/index.ts` | [`adapters/compatibility-lab.md`](adapters/compatibility-lab.md) | | `src/server/index/link-management.ts` | [`remote-link.md`](remote-link.md) | diff --git a/structure/clients/hermes.md b/structure/clients/hermes.md new file mode 100644 index 00000000000..75d4cccf66b --- /dev/null +++ b/structure/clients/hermes.md @@ -0,0 +1,60 @@ +# Hermes Profiles + +Hermes follows the shared [client-integration ownership contract](integrations.md); this page records +only how a Hermes profile is selected and owned. + +## Profile discovery + +Hermes keeps one home per profile. The default profile is the home OpenCodex has always written: +`HERMES_HOME`, else `%LOCALAPPDATA%\hermes` on Windows, else `~/.hermes` (`hermesHomeDir` in +`src/clients/config-export.ts`). A named profile is `/profiles//` with its own +`config.yaml`, and Hermes runs it with `HERMES_HOME` set to that directory. + +`src/integrations/hermes-profiles.ts` mirrors Hermes' own rules (NousResearch/hermes-agent +`hermes_constants.py`). `hermesRootDir` derives the root as `_hermes_root_of` does: no override, or an +override inside the platform home, means the platform home; `/profiles/` means ``; any +other override is its own root. `listHermesProfiles` lists the default profile first, then every directory +under `/profiles` whose name matches `^[a-z0-9][a-z0-9_-]{0,63}$`, is not `default`, carries one +of Hermes' identity markers (`config.yaml`, `.env`, `SOUL.md`, `profile.yaml`, `auth.json`, `state.db`) +and has no `profiles/.deleted/` tombstone. A symbolic link or junction, as the `profiles` directory +or as a profile, is never listed. A profile whose home is the default home (`HERMES_HOME` pointed at a +profile) is listed once, as the default. + +## Selection and ownership + +A management request selects a profile with `profile=` on `/api/client-integrations/hermes`, or +with `client=hermes&profile=` on the journal, preview, restore and restore-preview paths. +`GET /api/client-integrations/hermes/profiles` lists every profile with its own state. +`src/server/management/hermes-profile-routes.ts` resolves the selector into a request scope and removes +it before the generic routes in `src/server/management/integration-routes.ts` run, so no Aside handler +reads a Hermes name as an Aside account id. A scoped request, `profile=default` included, skips every +Aside delegation, and its history, restore and delete see only Hermes operations. Without a selector, +or with `profile=default`, the default profile, the root ownership store and the default paths are used +exactly as before. + +Each named profile owns `/hermes-profiles//`: its own records, journal and snapshots. +The generic writer runs unchanged with that store, an explicit target (`resolvedPaths`) and +`HERMES_HOME` set to the profile home. Only `providers.opencodex` is written; the profile's +`model.default` and other providers stay as they are. Mutation-flight keys carry the profile name, so a +second profile is busy while another profile's write is in flight, never joined to it. + +`hermesProfileBoundary` pins the `profiles` directory and the profile home by device and inode when the +scope is created. `guardedHermesProfileStore` re-checks it synchronously on every store access, and the +writer re-checks it immediately before any write: apply, overwrite and disable through the synchronous +`guard`, restore through `restoreGuard` (restore ignores `guard`, the background-refresh admission +hook, in `src/integrations/writer.ts`). It refuses a directory replaced by a link or junction, a +profile tombstoned meanwhile, a linked ownership store, and an existing record naming a different +`config.yaml`. The last rule keeps a same-named profile under a different Hermes root from taking over +ownership after `HERMES_HOME` moves: it reports `hermes_profile_owner_mismatch` until the profile is +disconnected under its original home. + +`src/cli/uninstall-integrations.ts` reads every Hermes profile store before any mutation and disables +each owned profile through the same coordinated writer; a record must name +`.../profiles//config.yaml`, and a store naming another client refuses the cleanup. + +Hermes has no automatic catalog refresh, for the default profile or any other; a stale profile is +updated by an explicit Apply. The unscoped journal lists the root store only, so named-profile history +is read and undone from that profile's page. The CLI has no profile selector. +`tests/clients/hermes-profile-routes.test.ts` and `tests/clients/hermes-profiles.test.ts` cover +discovery, scoping, journal and undo isolation, plan binding, flights, ownership moves, junction swaps +and uninstall. diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md index b3bd98583f0..c396385f462 100644 --- a/structure/clients/integrations.md +++ b/structure/clients/integrations.md @@ -285,7 +285,7 @@ non-empty list without `image` becomes `false`; an absent or empty modality list model object so Hermes receives no guessed capability. OpenCodex does not emit `supports_video` because its authoritative input-modality vocabulary currently has no video value. -> Decision record: [ADR-0090](../decisions/ADR-0090-hermes-model-capabilities.md) +> Decision record: [ADR-0090](../decisions/ADR-0090-hermes-model-capabilities.md). Profile selection and per-profile ownership: [Hermes Profiles](hermes.md). ## Hermes session affinity diff --git a/structure/manifest.json b/structure/manifest.json index 558c8e8b36e..273909d8ebf 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -857,6 +857,17 @@ "src/claude/" ] }, + { + "path": "clients/hermes.md", + "tier": 5, + "title": "Hermes Profiles", + "scope": "Hermes profile discovery, per-profile selection, ownership stores, and boundaries.", + "documents": [ + "src/cli/", + "src/integrations/", + "src/server/" + ] + }, { "path": "clients/qoder.md", "tier": 5, diff --git a/tests/clients/hermes-profile-routes.test.ts b/tests/clients/hermes-profile-routes.test.ts new file mode 100644 index 00000000000..15e0373592d --- /dev/null +++ b/tests/clients/hermes-profile-routes.test.ts @@ -0,0 +1,448 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { OPENCODE_PROVIDER_ID } from "../../src/clients/config-export"; +import { cleanupOwnedIntegrationsBeforeUninstall } from "../../src/cli/uninstall-integrations"; +import { INTEGRATION_CLIENTS } from "../../src/integrations/registry"; +import { createIntegrationStateStore, type IntegrationStateStore } from "../../src/integrations/store"; +import { HermesProfileError } from "../../src/integrations/hermes-profiles"; +import type { ManagementContext } from "../../src/server/management/context"; +import { hermesRequestScope, scopeHermesProfileRequest } from "../../src/server/management/hermes-profile-routes"; +import { disableIntegrationCoordinated, restoreIntegrationCoordinated } from "../../src/integrations/writer"; +import { handleManagementAPI } from "../../src/server/management-api"; +import { loadExportModels } from "../../src/server/management/model-rows"; +import { + setIntegrationMutationFlightTestHooks, + setIntegrationPathTestHooks, +} from "../../src/server/management/integration-routes"; +import type { OcxConfig } from "../../src/types"; +import { catalogConvergenceFactory } from "../helpers/catalog-convergence"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +/** + * Issue #7674: Hermes keeps one home per profile (the default home, then + * /profiles// with its own config.yaml), and Connect only ever wrote + * the default one. Every case drives the real route, writer and store against + * a temp home; the route never sees process.env. + */ +const routeEnv = {} as NodeJS.ProcessEnv; +let base = ""; +let home = ""; +let storeRoot = ""; +let store: IntegrationStateStore; +let config: OcxConfig; + +const DEFAULT_TEXT = "model:\n default: anthropic/claude-sonnet-4\n"; +const CODER_TEXT = "# coder bot\nmodel:\n default: openrouter/some-model\nproviders:\n other:\n api: https://other.example/v1\n"; + +function baseConfig(): OcxConfig { + return { + port: 10100, + hostname: "127.0.0.1", + defaultProvider: "a", + providers: { + a: { + adapter: "openai-chat", + baseUrl: "https://a.example/v1", + apiKey: "test-placeholder", + liveModels: false, + models: ["m1"], + modelContextWindows: { m1: 128_000 }, + }, + }, + } as unknown as OcxConfig; +} + +function defaultHome(): string { + return INTEGRATION_CLIENTS.hermes.detectDir(routeEnv, home); +} + +function profileHome(name: string): string { + return join(defaultHome(), "profiles", name); +} + +function makeProfile(name: string, files: Record): string { + const dir = profileHome(name); + mkdirSync(dir, { recursive: true }); + for (const [file, text] of Object.entries(files)) writeFileSync(join(dir, file), text); + return dir; +} + +beforeEach(() => { + base = mkdtempSync(join(tmpdir(), "ocx-hermes-profiles-")); + home = join(base, "home"); + storeRoot = join(base, "store", "integrations"); + mkdirSync(home, { recursive: true }); + store = createIntegrationStateStore(storeRoot); + config = baseConfig(); + setIntegrationMutationFlightTestHooks({ store }); + setIntegrationPathTestHooks({ env: routeEnv, home }); + mkdirSync(defaultHome(), { recursive: true }); + writeFileSync(join(defaultHome(), "config.yaml"), DEFAULT_TEXT); + makeProfile("coder", { "config.yaml": CODER_TEXT, "SOUL.md": "coder\n" }); +}); + +afterEach(() => { + setIntegrationMutationFlightTestHooks(null); + setIntegrationPathTestHooks(null); + removeTreeWithRetry(base); +}); + +async function api(path: string, init: RequestInit = {}): Promise { + const url = new URL("http://127.0.0.1:10100" + path); + const response = await handleManagementAPI( + new Request(url, { ...init, headers: { Host: url.host, ...(init.headers ?? {}) } }), + url, + config, + { saveConfigPreservingClaudeCode: () => {}, createManagementConvergeCodex: catalogConvergenceFactory() }, + ); + expect(response).not.toBeNull(); + return response!; +} + +function json(method: string, body: unknown): RequestInit { + return { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }; +} + +function profileStore(name: string): IntegrationStateStore { + return createIntegrationStateStore(join(storeRoot, "hermes-profiles", name)); +} + +const coderConfig = () => join(profileHome("coder"), "config.yaml"); +const defaultConfig = () => join(defaultHome(), "config.yaml"); + +describe("Hermes profile selection (#7674)", () => { + test("apply to a named profile writes only that profile and owns it in its own store", async () => { + const response = await api("/api/client-integrations/hermes?profile=coder", json("PUT", { enabled: true })); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ ok: true, clientId: "hermes", changed: true }); + + const written = readFileSync(coderConfig(), "utf8"); + const parsed = Bun.YAML.parse(written) as { + model: { default: string }; + providers: Record }>; + }; + // The profile's own model and providers survive; only our provider entry is added. + expect(parsed.model.default).toBe("openrouter/some-model"); + expect(parsed.providers.other).toBeDefined(); + expect(Object.keys(parsed.providers[OPENCODE_PROVIDER_ID]?.models ?? {})).toContain("a/m1"); + expect(written).toContain("# coder bot"); + expect(readFileSync(defaultConfig(), "utf8")).toBe(DEFAULT_TEXT); + + expect(store.readRecords().hermes).toBeUndefined(); + expect(profileStore("coder").readRecords().hermes?.configPath).toBe(coderConfig()); + }); + + test("the profile list reports each profile from its own store", async () => { + makeProfile("ghost", {}); + makeProfile("Upper", { "config.yaml": "x: 1\n" }); + makeProfile("gone", { "config.yaml": "x: 1\n" }); + mkdirSync(join(defaultHome(), "profiles", ".deleted", "gone"), { recursive: true }); + makeProfile("writer", { ".env": "" }); + expect((await api("/api/client-integrations/hermes?profile=coder", json("PUT", { enabled: true }))).status).toBe(200); + + const response = await api("/api/client-integrations/hermes/profiles"); + expect(response.status).toBe(200); + const body = await response.json() as { profiles: Array<{ hermesProfile: string; configPath: string; state: string; installed: boolean }> }; + expect(body.profiles.map(row => row.hermesProfile)).toEqual(["default", "coder", "writer"]); + expect(body.profiles.map(row => row.configPath)).toEqual([ + defaultConfig(), coderConfig(), join(profileHome("writer"), "config.yaml"), + ]); + expect(body.profiles.map(row => row.state)).toEqual(["absent", "current", "absent"]); + expect(body.profiles.every(row => row.installed)).toBe(true); + }); + + test("a scoped state read names the profile it describes", async () => { + const response = await api("/api/client-integrations/hermes?profile=coder"); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ clientId: "hermes", hermesProfile: "coder", configPath: coderConfig(), state: "absent" }); + }); + + test("journal, undo and disable stay inside the selected profile", async () => { + expect((await api("/api/client-integrations/hermes?profile=coder", json("PUT", { enabled: true }))).status).toBe(200); + const scoped = await api("/api/client-integrations/journal?client=hermes&profile=coder"); + expect(scoped.status).toBe(200); + const rows = (await scoped.json() as { operations: Array<{ opId: string; kind: string; configPath: string; undoable: boolean }> }).operations; + expect(rows.map(row => [row.kind, row.configPath, row.undoable])).toEqual([["apply", coderConfig(), true]]); + + const root = await api("/api/client-integrations/journal?client=hermes"); + expect((await root.json() as { operations: unknown[] }).operations).toEqual([]); + + // The root store does not know this operation, so an unscoped undo cannot reach it. + expect((await api("/api/client-integrations/restore", json("POST", { opId: rows[0]!.opId }))).status).toBe(404); + const undone = await api("/api/client-integrations/restore?client=hermes&profile=coder", json("POST", { opId: rows[0]!.opId })); + expect(undone.status).toBe(200); + expect(readFileSync(coderConfig(), "utf8")).toBe(CODER_TEXT); + expect(profileStore("coder").readRecords().hermes).toBeUndefined(); + + expect((await api("/api/client-integrations/hermes?profile=coder", json("PUT", { enabled: true }))).status).toBe(200); + const disabled = await api("/api/client-integrations/hermes?profile=coder", json("PUT", { enabled: false })); + expect(disabled.status).toBe(200); + expect(readFileSync(coderConfig(), "utf8")).toBe(CODER_TEXT); + expect(readFileSync(defaultConfig(), "utf8")).toBe(DEFAULT_TEXT); + }); + + test("a confirmed plan binds to the selected profile", async () => { + await loadExportModels(config); + const preview = await api("/api/client-integrations/preview?client=hermes&profile=coder", json("POST", { clientId: "hermes", operation: "apply" })); + expect(preview.status).toBe(200); + const plan = await preview.json() as { fingerprint: string; clientId: string; canApply: boolean }; + expect(plan.clientId).toBe("hermes"); + expect(plan.canApply).toBe(true); + + const mismatched = await api("/api/client-integrations/preview?client=hermes&profile=coder", json("POST", { clientId: "pi", operation: "apply" })); + expect(mismatched.status).toBe(400); + + // The default profile's plan describes a different file, so it cannot confirm this one. + const defaultPlan = await (await api("/api/client-integrations/preview", json("POST", { clientId: "hermes", operation: "apply" }))).json() as { fingerprint: string }; + expect(defaultPlan.fingerprint).not.toBe(plan.fingerprint); + const crossBound = await api("/api/client-integrations/hermes?profile=coder", json("PUT", { + enabled: true, operation: "apply", planFingerprint: defaultPlan.fingerprint, + })); + expect(crossBound.status).toBe(409); + expect(readFileSync(coderConfig(), "utf8")).toBe(CODER_TEXT); + + const applied = await api("/api/client-integrations/hermes?profile=coder", json("PUT", { + enabled: true, operation: "apply", planFingerprint: plan.fingerprint, + })); + expect(applied.status).toBe(200); + expect(profileStore("coder").readRecords().hermes?.configPath).toBe(coderConfig()); + expect(readFileSync(defaultConfig(), "utf8")).toBe(DEFAULT_TEXT); + }); + + test("invalid and unknown profiles are refused before anything is written", async () => { + for (const [selector, status] of [["..%2Fcoder", 400], ["Coder", 400], ["ghost", 404], ["", 400]] as const) { + const response = await api("/api/client-integrations/hermes?profile=" + selector, json("PUT", { enabled: true })); + expect(response.status).toBe(status); + } + expect(readFileSync(coderConfig(), "utf8")).toBe(CODER_TEXT); + expect(existsSync(join(storeRoot, "hermes-profiles"))).toBe(false); + // Profile selectors on other clients are still refused. + expect((await api("/api/client-integrations/pi?profile=coder", json("PUT", { enabled: true }))).status).toBe(400); + }); + + test("no selector and profile=default keep writing the default profile", async () => { + expect((await api("/api/client-integrations/hermes?profile=default", json("PUT", { enabled: true }))).status).toBe(200); + expect(store.readRecords().hermes?.configPath).toBe(defaultConfig()); + expect(readFileSync(coderConfig(), "utf8")).toBe(CODER_TEXT); + expect((await api("/api/client-integrations/hermes", json("PUT", { enabled: false }))).status).toBe(200); + expect(readFileSync(defaultConfig(), "utf8")).toBe(DEFAULT_TEXT); + }); + + test("uninstall removes the provider from every owned profile", async () => { + expect((await api("/api/client-integrations/hermes", json("PUT", { enabled: true }))).status).toBe(200); + expect((await api("/api/client-integrations/hermes?profile=coder", json("PUT", { enabled: true }))).status).toBe(200); + const result = await cleanupOwnedIntegrationsBeforeUninstall({ + createStore: () => store, + loadConfig: () => config, + loadModels: () => loadExportModels(config), + disable: input => disableIntegrationCoordinated(input), + env: routeEnv, + home, + }); + expect(result).toEqual({ attempted: 2, changed: 2 }); + expect(readFileSync(coderConfig(), "utf8")).toBe(CODER_TEXT); + expect(readFileSync(defaultConfig(), "utf8")).toBe(DEFAULT_TEXT); + expect(profileStore("coder").readRecords()).toEqual({}); + }); +}); + +describe("Hermes profile scope boundaries (#7674 audit)", () => { + afterEach(() => { delete routeEnv.HERMES_HOME; }); + + test("a second profile is busy while another profile's write is in flight, never joined", async () => { + makeProfile("writer", { "config.yaml": CODER_TEXT }); + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + let entered!: () => void; + const started = new Promise(resolve => { entered = resolve; }); + setIntegrationMutationFlightTestHooks({ store, run: async operation => { entered(); await gate; return operation(); } }); + const coder = api("/api/client-integrations/hermes?profile=coder", json("PUT", { enabled: true })); + await started; + const writer = await api("/api/client-integrations/hermes?profile=writer", json("PUT", { enabled: true })); + expect(writer.status).toBe(409); + expect(await writer.json()).toMatchObject({ code: "integration_mutation_busy" }); + const fallback = await api("/api/client-integrations/hermes", json("PUT", { enabled: true })); + expect(fallback.status).toBe(409); + release(); + expect((await coder).status).toBe(200); + expect(readFileSync(join(profileHome("writer"), "config.yaml"), "utf8")).toBe(CODER_TEXT); + expect(profileStore("writer").readRecords()).toEqual({}); + expect(readFileSync(defaultConfig(), "utf8")).toBe(DEFAULT_TEXT); + }); + + test("a Hermes-scoped history request never reaches another client's operation", async () => { + mkdirSync(INTEGRATION_CLIENTS.pi.detectDir(routeEnv, home), { recursive: true }); + expect((await api("/api/client-integrations/pi", json("PUT", { enabled: true }))).status).toBe(200); + const piOp = store.listOperations("pi")[0]!.opId; + store.appendJournal({ + opId: "aside-foreign-op", clientId: "aside", kind: "apply", at: new Date().toISOString(), + configPath: join(home, "aside-config.json"), snapshot: { kind: "none" }, + resultFingerprint: "", resultAbsent: true, priorRecord: null, + }); + for (const profile of ["default", "coder"]) { + for (const opId of [piOp, "aside-foreign-op"]) { + const scope = "client=hermes&profile=" + profile; + expect((await api("/api/client-integrations/restore?" + scope, json("POST", { opId }))).status).toBe(404); + expect((await api("/api/client-integrations/restore/preview?" + scope, json("POST", { opId }))).status).toBe(404); + expect((await api("/api/client-integrations/journal?" + scope + "&opId=" + opId, { method: "DELETE" })).status).toBe(404); + } + const rows = await api("/api/client-integrations/journal?client=hermes&profile=" + profile); + expect((await rows.json() as { operations: Array<{ clientId: string }> }).operations.every(row => row.clientId === "hermes")).toBe(true); + } + expect(store.listOperations("pi").map(row => row.opId)).toEqual([piOp]); + }); + + test("a profile of the same name under another Hermes home cannot take over its ownership", async () => { + expect((await api("/api/client-integrations/hermes?profile=coder", json("PUT", { enabled: true }))).status).toBe(200); + // Resolved before HERMES_HOME moves: afterwards coderConfig() names the other root's profile. + const originalPath = coderConfig(); + const original = readFileSync(originalPath, "utf8"); + const otherRoot = join(base, "other-hermes"); + mkdirSync(join(otherRoot, "profiles", "coder"), { recursive: true }); + writeFileSync(join(otherRoot, "config.yaml"), DEFAULT_TEXT); + writeFileSync(join(otherRoot, "profiles", "coder", "config.yaml"), CODER_TEXT); + routeEnv.HERMES_HOME = otherRoot; + + for (const body of [{ enabled: true }, { enabled: true, overwriteConflict: true }, { enabled: false }]) { + const response = await api("/api/client-integrations/hermes?profile=coder", json("PUT", body)); + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ code: "hermes_profile_owner_mismatch" }); + } + expect(readFileSync(join(otherRoot, "profiles", "coder", "config.yaml"), "utf8")).toBe(CODER_TEXT); + expect(readFileSync(originalPath, "utf8")).toBe(original); + expect(original).toContain("opencodex:"); + expect(profileStore("coder").readRecords().hermes?.configPath).toBe(originalPath); + const listed = await (await api("/api/client-integrations/hermes/profiles")).json() as { profiles: Array<{ hermesProfile: string; state: string; error?: string }> }; + expect(listed.profiles.find(row => row.hermesProfile === "coder")).toMatchObject({ state: "unsafe", error: expect.any(String) }); + }); + + test("a linked profile is not listed, and a profile swapped for a link before the write is refused", async () => { + const elsewhere = join(base, "elsewhere"); + mkdirSync(elsewhere, { recursive: true }); + writeFileSync(join(elsewhere, "config.yaml"), CODER_TEXT); + symlinkSync(elsewhere, profileHome("linked"), "junction"); + expect((await api("/api/client-integrations/hermes?profile=linked", json("PUT", { enabled: true }))).status).toBe(404); + + const moved = join(defaultHome(), "profiles", "coder-moved"); + setIntegrationMutationFlightTestHooks({ + store, + run: async operation => { + renameSync(profileHome("coder"), moved); + symlinkSync(moved, profileHome("coder"), "junction"); + return operation(); + }, + }); + const response = await api("/api/client-integrations/hermes?profile=coder", json("PUT", { enabled: true })); + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ code: "integration_unsafe" }); + expect(readFileSync(join(moved, "config.yaml"), "utf8")).toBe(CODER_TEXT); + expect(readFileSync(join(elsewhere, "config.yaml"), "utf8")).toBe(CODER_TEXT); + expect(profileStore("coder").readRecords()).toEqual({}); + }); +}); + +describe("Hermes profile boundaries at the last step (#7674 audit round 2)", () => { + test("restore runs its synchronous restoreGuard after revalidation and honors its refusal", async () => { + expect((await api("/api/client-integrations/hermes?profile=coder", json("PUT", { enabled: true }))).status).toBe(200); + const applied = readFileSync(coderConfig(), "utf8"); + const opId = profileStore("coder").listOperations("hermes")[0]!.opId; + const order: string[] = []; + const result = await restoreIntegrationCoordinated({ + clientId: "hermes", models: await loadExportModels(config), config, port: 10100, + store: profileStore("coder"), env: { ...routeEnv, HERMES_HOME: profileHome("coder") }, home, + resolvedPaths: { configPath: coderConfig(), detectDir: profileHome("coder") }, + opId, confirmDrift: false, + }, { + revalidate: async () => { order.push("revalidate"); return null; }, + // The background-refresh guard stays ignored by restore; restoreGuard is the restore hook. + guard: () => { + order.push("guard"); + return null; + }, + restoreGuard: () => { + order.push("restoreGuard"); + return { ok: false, reason: "unsafe", state: "unsafe", clientId: "hermes", message: "boundary moved" }; + }, + }); + expect(order).toEqual(["revalidate", "restoreGuard"]); + expect(result).toMatchObject({ ok: false, message: "boundary moved" }); + expect(readFileSync(coderConfig(), "utf8")).toBe(applied); + }); + + test("the profile list never reads through a linked ownership store", async () => { + const foreign = join(base, "foreign-store"); + mkdirSync(join(foreign, "snapshots"), { recursive: true }); + // A valid pending retry: reading through this store would prune and rewrite this file. + writeFileSync(join(foreign, "maintenance.json"), JSON.stringify({ pruneFailures: { hermes: { at: new Date(0).toISOString(), error: "pending" } } })); + mkdirSync(join(storeRoot, "hermes-profiles"), { recursive: true }); + symlinkSync(foreign, join(storeRoot, "hermes-profiles", "coder"), "junction"); + const before = readFileSync(join(foreign, "maintenance.json"), "utf8"); + + const response = await api("/api/client-integrations/hermes/profiles"); + expect(response.status).toBe(200); + const rows = (await response.json() as { profiles: Array<{ hermesProfile: string; state: string; error?: string }> }).profiles; + expect(rows.find(row => row.hermesProfile === "coder")).toMatchObject({ state: "unsafe", error: expect.any(String) }); + expect(readFileSync(join(foreign, "maintenance.json"), "utf8")).toBe(before); + expect((await api("/api/client-integrations/hermes?profile=coder", json("PUT", { enabled: true }))).status).toBe(409); + expect(readFileSync(coderConfig(), "utf8")).toBe(CODER_TEXT); + }); + + test("uninstall refuses a profile swapped for a link while models load", async () => { + expect((await api("/api/client-integrations/hermes?profile=coder", json("PUT", { enabled: true }))).status).toBe(200); + const applied = readFileSync(coderConfig(), "utf8"); + const decoy = join(base, "decoy"); + mkdirSync(decoy, { recursive: true }); + writeFileSync(join(decoy, "config.yaml"), applied); + const moved = join(defaultHome(), "profiles", "coder-moved"); + await expect(cleanupOwnedIntegrationsBeforeUninstall({ + createStore: () => store, + loadConfig: () => config, + loadModels: async () => { + renameSync(profileHome("coder"), moved); + symlinkSync(decoy, profileHome("coder"), "junction"); + return loadExportModels(config); + }, + disable: (input, options) => disableIntegrationCoordinated(input, options), + env: routeEnv, + home, + })).rejects.toThrow(/refused/); + expect(readFileSync(join(decoy, "config.yaml"), "utf8")).toBe(applied); + expect(readFileSync(join(moved, "config.yaml"), "utf8")).toBe(applied); + expect(profileStore("coder").readRecords().hermes).toBeDefined(); + }); +}); + +describe("Hermes profile store facade (#7674 audit round 3)", () => { + test("a store swapped after the scope was created refuses every access", async () => { + expect((await api("/api/client-integrations/hermes?profile=coder", json("PUT", { enabled: true }))).status).toBe(200); + const url = new URL("http://127.0.0.1:10100/api/client-integrations/journal?client=hermes&profile=coder"); + const req = new Request(url, { method: "DELETE" }); + const scoped = scopeHermesProfileRequest({ req, url, config } as unknown as ManagementContext, { store, env: routeEnv, home }); + expect(scoped).not.toBeInstanceOf(Response); + const scope = hermesRequestScope(req)!; + const opId = scope.store.listOperations("hermes")[0]!.opId; + // Captured before the swap, as a writer holds it through a transaction. + const io = scope.store.io(); + const record = scope.store.readRecords().hermes!; + const entry = scope.store.listOperations("hermes")[0]!; + + const foreign = join(base, "foreign-store"); + mkdirSync(foreign, { recursive: true }); + const marker = JSON.stringify({ pruneFailures: { hermes: { at: new Date(0).toISOString(), error: "pending" } } }); + writeFileSync(join(foreign, "maintenance.json"), marker); + renameSync(join(storeRoot, "hermes-profiles", "coder"), join(storeRoot, "hermes-profiles", "coder-real")); + symlinkSync(foreign, join(storeRoot, "hermes-profiles", "coder"), "junction"); + + expect(() => scope.store.readRecords()).toThrow(HermesProfileError); + expect(() => scope.store.retryPendingPrunes()).toThrow(HermesProfileError); + expect(() => scope.store.retireOperation({ tombstone: opId, at: new Date().toISOString(), by: "admin-token" })).toThrow(HermesProfileError); + expect(() => scope.store.io()).toThrow(HermesProfileError); + expect(() => io.putRecord(record)).toThrow(HermesProfileError); + expect(() => io.dropRecord("hermes")).toThrow(HermesProfileError); + expect(() => io.appendJournal(entry)).toThrow(HermesProfileError); + expect(readFileSync(join(foreign, "maintenance.json"), "utf8")).toBe(marker); + expect(readdirSync(foreign)).toEqual(["maintenance.json"]); + }); +}); diff --git a/tests/clients/hermes-profiles.test.ts b/tests/clients/hermes-profiles.test.ts new file mode 100644 index 00000000000..d1cd4e36ec9 --- /dev/null +++ b/tests/clients/hermes-profiles.test.ts @@ -0,0 +1,58 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { hermesHomeDir } from "../../src/clients/config-export"; +import { hermesRootDir, listHermesProfiles, resolveHermesProfile } from "../../src/integrations/hermes-profiles"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +/** Root derivation mirrors Hermes _hermes_root_of (NousResearch/hermes-agent hermes_constants.py). */ +let base = ""; +let home = ""; +const native = () => hermesHomeDir({}, home); + +beforeEach(() => { + base = mkdtempSync(join(tmpdir(), "ocx-hermes-root-")); + home = join(base, "home"); + mkdirSync(home, { recursive: true }); +}); + +afterEach(() => removeTreeWithRetry(base)); + +describe("hermesRootDir", () => { + test("no override and an override inside the platform home both mean the platform home", () => { + expect(hermesRootDir({}, home)).toBe(native()); + expect(hermesRootDir({ HERMES_HOME: join(native(), "profiles", "coder") }, home)).toBe(native()); + }); + + test("/profiles/ names ; any other override is its own root", () => { + const custom = join(base, "data", "hermes"); + expect(hermesRootDir({ HERMES_HOME: join(custom, "profiles", "coder") }, home)).toBe(custom); + expect(hermesRootDir({ HERMES_HOME: custom }, home)).toBe(custom); + }); + + test.if(process.platform === "win32")("Windows uses %LOCALAPPDATA%\\hermes as the platform home", () => { + const local = join(base, "LocalAppData"); + expect(hermesRootDir({ LOCALAPPDATA: local }, home)).toBe(join(local, "hermes")); + }); +}); + +describe("listHermesProfiles", () => { + test("HERMES_HOME pointed at a profile makes it the default target and keeps its siblings", () => { + for (const name of ["coder", "writer"]) { + mkdirSync(join(native(), "profiles", name), { recursive: true }); + writeFileSync(join(native(), "profiles", name, "config.yaml"), "x: 1\n"); + } + const env = { HERMES_HOME: join(native(), "profiles", "coder") }; + expect(listHermesProfiles(env, home).map(row => [row.name, row.home])).toEqual([ + ["default", join(native(), "profiles", "coder")], + ["writer", join(native(), "profiles", "writer")], + ]); + expect(() => resolveHermesProfile("coder", env, home)).toThrow(); + expect(resolveHermesProfile("writer", env, home).configPath).toBe(join(native(), "profiles", "writer", "config.yaml")); + }); + + test("a missing Hermes home lists only the default profile", () => { + expect(listHermesProfiles({}, home).map(row => row.name)).toEqual(["default"]); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 446f2bcf0c5..d41fccb67a1 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1411,6 +1411,7 @@ "codex-set-prompt.test.ts": "gui", "codex-prompt-writes.test.ts": "codex-integration", "codex-prompt-scalar-scope.test.ts": "codex-integration", "advisor-settings.test.ts": "advisor", "advisor-context.test.ts": "advisor", "advisor-state.test.ts": "advisor", "advisor-guard.test.ts": "advisor", "advisor-consult.test.ts": "advisor", "advisor-plan.test.ts": "advisor", "advisor-responses-wiring.test.ts": "advisor", "advisor-routes.test.ts": "server", "advisor-internal-authority.test.ts": "advisor", "advisor-core-boundary.test.ts": "advisor", "advisor-continuation-limit.test.ts": "advisor", "advisor-authority-transport.test.ts": "advisor", "advisor-evidence.test.ts": "advisor", "advisor-budget.test.ts": "advisor", "advisor-adaptive.test.ts": "advisor" ,"link-codex-settings-digest.test.ts": "clients", "link-apply-record.test.ts": "clients", "client-runtime-restart.test.ts": "clients", "link-parity-reporter.test.ts": "clients", - "link-apply-restart.test.ts": "cli", "link-child-parity.test.ts": "server", "restart-replacement-not-ready.test.ts": "server" + "link-apply-restart.test.ts": "cli", "link-child-parity.test.ts": "server", "restart-replacement-not-ready.test.ts": "server", + "hermes-profiles.test.ts": "clients", "hermes-profile-routes.test.ts": "clients" ,"cli-sync-expect-link.test.ts": "cli" } From 7b0e4abd168ae6355b50873b1ae4fbe93ac4f007 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 25 Oct 2026 09:27:02 +0000 Subject: [PATCH 057/101] fix(socks5): deliver partial response payloads promptly and release terminal sockets (#7662) * fix(test): promote Bun 1.4.4 with independent runner role verification * docs: align Docker Bun comments with unified role defaults * test: use a draining TLS origin for early upload replies * test: confine draining origin to buffered HTTP/1 upload * fix(socks5): stream response bytes promptly and release completed connections Use bounded available-byte reads for response payload, retain exact framing checks, and discard terminal buffers without unshifting after EOF. Add loopback regressions for incremental fixed/chunked/gzip/deflate responses and response lifecycle cleanup. Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> * fix(socks5): declare receive buffer for zero-copy socket views Explicitly type the receive buffer as Buffer so the project Node declarations accept ArrayBufferLike-backed socket views without copying them. Fixes the exact-head CI typecheck and dependent translator-budget typecheck regression. Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> * test(socks5): observe terminal errors after incremental payload delivery Keep the huge-chunk error and socket cleanup regression while allowing valid payload to arrive before the injected failure. Assert the exact injected error rather than treating the first successful read as terminal success. Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --------- Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> Co-authored-by: lidge-jun Co-authored-by: JUN --- src/lib/socks5-fetch.ts | 65 ++++++-- structure/transports/inventory.md | 11 ++ tests/lib/socks5-fetch.test.ts | 10 +- tests/lib/socks5-tls-lifecycle.test.ts | 221 +++++++++++++++++++++++++ 4 files changed, 288 insertions(+), 19 deletions(-) diff --git a/src/lib/socks5-fetch.ts b/src/lib/socks5-fetch.ts index 3d2a106f071..abc5795232b 100644 --- a/src/lib/socks5-fetch.ts +++ b/src/lib/socks5-fetch.ts @@ -91,7 +91,7 @@ function connectSocket(hostname: string, port: number, signal?: AbortSignal): Pr } class SocketReader { - private buffer = Buffer.alloc(0); + private buffer: Buffer = Buffer.alloc(0); private ended = false; private failure: Error | undefined; private readonly exactWaiters: Array<{ @@ -100,6 +100,7 @@ class SocketReader { reject: (error: unknown) => void; }> = []; private readonly anyWaiters: Array<{ + maxBytes: number; resolve: (value: Buffer) => void; reject: (error: unknown) => void; }> = []; @@ -123,10 +124,14 @@ class SocketReader { private readonly onData = (chunk: Buffer | string): void => { const value = typeof chunk === "string" ? Buffer.from(chunk) : chunk; if (this.anyWaiters.length > 0) { - this.anyWaiters.shift()!.resolve(value); + const waiter = this.anyWaiters.shift()!; + const end = Math.min(value.byteLength, waiter.maxBytes); + if (end < value.byteLength) this.buffer = value.subarray(end); + if (this.buffer.byteLength >= MAX_BODY_SLICE_BYTES) this.socket.pause(); + waiter.resolve(end === value.byteLength ? value : value.subarray(0, end)); return; } - this.buffer = Buffer.concat([this.buffer, value]); + this.buffer = this.buffer.byteLength === 0 ? value : Buffer.concat([this.buffer, value]); this.flushExact(); if (this.buffer.byteLength >= MAX_BODY_SLICE_BYTES) this.socket.pause(); }; @@ -211,12 +216,13 @@ class SocketReader { } } - readAny(signal?: AbortSignal): Promise { + readAny(signal?: AbortSignal, maxBytes = Number.MAX_SAFE_INTEGER): Promise { if (signal?.aborted) return Promise.reject(signal.reason ?? new Error("The operation was aborted")); if (this.buffer.byteLength > 0) { - const value = this.buffer; - this.buffer = Buffer.alloc(0); - this.socket.resume(); + const end = Math.min(this.buffer.byteLength, maxBytes); + const value = end === this.buffer.byteLength ? this.buffer : this.buffer.subarray(0, end); + this.buffer = end === this.buffer.byteLength ? Buffer.alloc(0) : this.buffer.subarray(end); + if (this.buffer.byteLength < MAX_BODY_SLICE_BYTES) this.socket.resume(); return Promise.resolve(value); } if (this.failure) return Promise.reject(this.failure); @@ -228,6 +234,7 @@ class SocketReader { waiter.reject(signal?.reason ?? new Error("The operation was aborted")); }; const waiter = { + maxBytes, resolve: (value: Buffer) => { signal?.removeEventListener("abort", onAbort); resolve(value); }, reject: (error: unknown) => { signal?.removeEventListener("abort", onAbort); reject(error); }, }; @@ -238,13 +245,17 @@ class SocketReader { }); } - dispose(): void { + dispose(preserveBuffered = false): void { this.socket.pause(); this.socket.removeListener("data", this.onData); this.socket.removeListener("error", this.onError); this.socket.removeListener("end", this.onEnd); this.socket.removeListener("close", this.onEnd); - if (this.buffer.byteLength > 0) this.socket.unshift(this.buffer); + // Only a successful handshake hands unread bytes to another socket owner. + // Terminal cleanup must not unshift after EOF or retain abandoned payload. + if (preserveBuffered && !this.ended && !this.socket.destroyed && this.buffer.byteLength > 0) { + this.socket.unshift(this.buffer); + } this.buffer = Buffer.alloc(0); this.rejectExact(new Socks5FetchError("SOCKS5 reader disposed")); this.rejectAny(new Socks5FetchError("SOCKS5 reader disposed")); @@ -269,7 +280,7 @@ async function socks5Connect(proxy: string, target: URL, signal?: AbortSignal): if (error instanceof Socks5HandshakeError) throw new Socks5FetchError(error.message); throw error; } finally { - reader.dispose(); + reader.dispose(true); } } @@ -424,6 +435,7 @@ function responseBody( const chunked = (headers.get("transfer-encoding") ?? "").toLowerCase().split(",").some(value => value.trim() === "chunked"); let remaining = contentLength; let chunkRemaining = 0; + let chunkTerminatorPending = false; let complete = false; const finish = () => { if (complete) return; @@ -435,8 +447,24 @@ function responseBody( }; const onAbort = () => socket.destroy(); signal.addEventListener("abort", onAbort, { once: true }); + // Framing needs exact reads, payload does not: waiting to fill 64 KiB can hold + // many complete SSE events behind bytes the upstream has not generated yet. + const readPayload = async (length: number): Promise => { + const value = await reader.readAny(signal, Math.min(length, MAX_BODY_SLICE_BYTES)); + if (value.byteLength === 0) { + throw new Socks5FetchError("SOCKS5 socket ended before the expected bytes arrived"); + } + return value; + }; const readChunk = async (): Promise => { if (chunked) { + // Verify the previous chunk's delimiter before accepting another chunk, + // without holding already-received payload while the delimiter is delayed. + if (chunkTerminatorPending) { + const ending = await reader.read(2, signal); + if (!ending.equals(CRLF)) throw new Socks5FetchError("SOCKS5 upstream returned an invalid chunk terminator"); + chunkTerminatorPending = false; + } if (chunkRemaining === 0) { const line = (await reader.readUntil(CRLF, MAX_RESPONSE_HEADER_BYTES, signal)).subarray(0, -2).toString("ascii"); const size = parseChunkSize(line); @@ -449,21 +477,18 @@ function responseBody( } chunkRemaining = size; } - const value = await reader.read(Math.min(chunkRemaining, MAX_BODY_SLICE_BYTES), signal); + const value = await readPayload(chunkRemaining); chunkRemaining -= value.byteLength; - if (chunkRemaining === 0) { - const ending = await reader.read(2, signal); - if (!ending.equals(CRLF)) throw new Socks5FetchError("SOCKS5 upstream returned an invalid chunk terminator"); - } + chunkTerminatorPending = chunkRemaining === 0; return value; } if (remaining !== undefined) { if (remaining === 0) return null; - const value = await reader.read(Math.min(remaining, MAX_BODY_SLICE_BYTES), signal); + const value = await readPayload(remaining); remaining -= value.byteLength; return value; } - const value = await reader.readAny(signal); + const value = await reader.readAny(signal, MAX_BODY_SLICE_BYTES); return value.byteLength === 0 ? null : value; }; return new ReadableStream({ @@ -476,6 +501,12 @@ function responseBody( return; } controller.enqueue(value); + // A fixed-length response needs no further read to prove completion. + // Release its connection even when the consumer leaves this chunk queued. + if (!chunked && remaining === 0) { + finish(); + controller.close(); + } } catch (error) { finish(); controller.error(error); diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index ae7a41092ab..e5080656d98 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -360,6 +360,17 @@ fragmented framing, header limits and explicit-route snapshot preservation. Explicit `http2` / `h2` pins reject before network I/O: this HTTP/1.1 tunnel cannot honor them and must not silently downgrade the provider contract. +Response payload is delivered as bytes arrive, in raw slices of at most 64 KiB, rather than +waiting to fill a slice or an entire declared HTTP chunk. The next pull validates a completed +chunk's trailing CRLF before reading another chunk; premature EOF and malformed framing still +error the body. Fixed-length completion releases the socket as soon as the declared bytes are +queued, without requiring an extra consumer pull. Terminal reader cleanup discards unread bytes; +only the successful SOCKS handshake preserves bytes for the next socket owner. Payload reads +use existing byte views instead of accumulating an exact-length copy. Coding limits, +certificate verification, proxy selection and upload ownership are unchanged. +`tests/lib/socks5-tls-lifecycle.test.ts` also covers partial payloads, compressed SSE, delayed +framing, truncation, cancellation and connection release. + The upload and the answer are read together. One reader consumes the socket for the whole exchange, starting before the request body is finished, because a peer may answer a request it has not finished receiving and a caller's body stream may stall. Each body read and drain wait diff --git a/tests/lib/socks5-fetch.test.ts b/tests/lib/socks5-fetch.test.ts index 0370bf4b4e7..5f26106ea36 100644 --- a/tests/lib/socks5-fetch.test.ts +++ b/tests/lib/socks5-fetch.test.ts @@ -731,10 +731,15 @@ describe("socks5Fetch", () => { ); const reader = response.body?.getReader(); if (!reader) throw new Error("huge chunked response had no body"); - const pending = reader.read(); + // Payload may already be delivered before the injected transport failure. + // Observe the whole read lifetime, not just the first available slice. + const pending = (async () => { + while (!(await reader.read()).done) { /* consume the valid prefix */ } + })(); await Bun.sleep(50); if (!clientSocket) throw new Error("SOCKS5 client socket was not captured"); - clientSocket.destroy(new Error("test chunk stream failure")); + const failure = new Error("test chunk stream failure"); + clientSocket.destroy(failure); const outcome = await Promise.race([ pending.then( result => result, @@ -743,6 +748,7 @@ describe("socks5Fetch", () => { Bun.sleep(1_000).then(() => "timed out"), ]); expect(outcome).toBeInstanceOf(Error); + expect(outcome).toBe(failure); expect(await awaitDestroyed(clientSocket)).toBe(true); expect(clientSocket.listenerCount("data")).toBe(0); expect(clientSocket.listenerCount("error")).toBe(0); diff --git a/tests/lib/socks5-tls-lifecycle.test.ts b/tests/lib/socks5-tls-lifecycle.test.ts index a3f858d05c3..16358e6c178 100644 --- a/tests/lib/socks5-tls-lifecycle.test.ts +++ b/tests/lib/socks5-tls-lifecycle.test.ts @@ -1,5 +1,6 @@ import { describe, test } from "bun:test"; import assert from "node:assert/strict"; +import { constants as zlibConstants, createDeflate, createGzip } from "node:zlib"; import { createServer, type AddressInfo, type Socket } from "node:net"; import { createUploadWaiter } from "../../src/lib/socks5-upload-waiter"; import { socks5Fetch } from "../../src/lib/socks5-fetch"; @@ -115,6 +116,226 @@ async function checkHandshakeFailure(failure: Failure): Promise { } } +/** A local SOCKS peer owns response bytes; no DNS, credentials or provider calls. */ +async function withResponsePeer( + reply: (peer: Socket) => void, + check: (response: Response, peer: Socket, closed: Promise, abort: AbortController) => Promise, +): Promise { + const sockets = new Set(); + let peer!: Socket; + let markClosed!: () => void; + const closed = new Promise(resolve => { markClosed = resolve; }); + const server = createServer(socket => { + peer = socket; + sockets.add(socket); + socket.on("error", () => { /* intentional abort/truncation */ }); + socket.on("close", () => { sockets.delete(socket); markClosed(); }); + let stage: "greeting" | "connect" | "http" | "body" = "greeting"; + let pending = Buffer.alloc(0); + socket.on("data", chunk => { + if (stage === "body") return; + pending = Buffer.concat([pending, typeof chunk === "string" ? Buffer.from(chunk) : chunk]); + if (stage === "greeting") { + if (pending.length < 2 || pending.length < 2 + pending[1]!) return; + pending = pending.subarray(2 + pending[1]!); + socket.write(Buffer.from([5, 0])); + stage = "connect"; + } + if (stage === "connect") { + if (pending.length < 5 || pending.length < 7 + pending[4]!) return; + pending = pending.subarray(7 + pending[4]!); + socket.write(Buffer.from([5, 0, 0, 1, 127, 0, 0, 1, 0, 80])); + stage = "http"; + } + if (stage === "http" && pending.includes("\r\n\r\n")) { + stage = "body"; + pending = Buffer.alloc(0); + reply(socket); + } + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const abort = new AbortController(); + let timer: ReturnType | undefined; + const deadline = new Promise((_, reject) => { + timer = setTimeout(() => { + const error = new Error("response fixture watchdog"); + abort.abort(error); + reject(error); + }, 2_000); + }); + try { + const run = async () => { + const port = (server.address() as AddressInfo).port; + const response = await socks5Fetch("http://destination.invalid/stream", { signal: abort.signal }, `socks5://127.0.0.1:${port}`); + await check(response, peer, closed, abort); + }; + await Promise.race([run(), deadline]); + } finally { + clearTimeout(timer); + abort.abort(); + for (const socket of sockets) socket.destroy(); + await new Promise(resolve => server.close(() => resolve())); + } +} + +async function collectResponse(reader: ReadableStreamDefaultReader, maxBytes = 64 * 1024): Promise { + const parts: Buffer[] = []; + for (;;) { + const result = await reader.read(); + if (result.done) break; + assert.ok(result.value.byteLength > 0 && result.value.byteLength <= maxBytes); + parts.push(Buffer.from(result.value)); + } + return Buffer.concat(parts); +} + +describe("SOCKS5 incremental response lifecycle", () => { + for (const framing of ["fixed", "chunked"] as const) { + test(`${framing} payload arrives before the rest of the declared length exists`, async () => { + const prefix = Buffer.from("data: first\n\n"); + const rest = Buffer.alloc(128 * 1024, 120); + const length = prefix.length + rest.length; + const header = framing === "fixed" ? `Content-Length: ${length}\r\n` : "Transfer-Encoding: chunked\r\n"; + await withResponsePeer(peer => { + peer.write(`HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\n${header}\r\n`); + if (framing === "chunked") peer.write(`${length.toString(16)}\r\n`); + peer.write(prefix); + }, async (response, peer) => { + const reader = response.body!.getReader(); + try { + const first = await reader.read(); + assert.equal(first.done, false); + assert.deepEqual(Buffer.from(first.value!), prefix); + // No timing assertion: the peer only creates the remaining bytes AFTER + // the first read succeeds. A buffering regression reaches the watchdog. + peer.write(rest); + peer.end(framing === "chunked" ? "\r\n0\r\nX-Fixture: yes\r\n\r\n" : ""); + assert.deepEqual(await collectResponse(reader), rest); + } finally { reader.releaseLock(); } + }); + }); + } + + test("a complete chunk payload does not wait for its trailing CRLF", async () => { + await withResponsePeer(peer => { + peer.write("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n2\r\nok"); + }, async (response, peer) => { + const reader = response.body!.getReader(); + try { + assert.equal(Buffer.from((await reader.read()).value!).toString(), "ok"); + peer.end("\r\n0\r\n\r\n"); + assert.equal((await reader.read()).done, true); + } finally { reader.releaseLock(); } + }); + }); + + test("fixed-length completion releases the socket before the queued body is consumed", async () => { + await withResponsePeer(peer => { + peer.write("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: keep-alive\r\n\r\nok"); + }, async (response, _peer, closed) => { + await closed; + assert.equal(await response.text(), "ok"); + }); + }); + + for (const [name, wire] of [ + ["truncated fixed body", "Content-Length: 10\r\n\r\nok"], + ["truncated chunk payload", "Transfer-Encoding: chunked\r\n\r\na\r\nok"], + ["missing chunk terminator", "Transfer-Encoding: chunked\r\n\r\n2\r\nok"], + ["invalid chunk terminator", "Transfer-Encoding: chunked\r\n\r\n2\r\nokXX0\r\n\r\n"], + ["invalid chunk size with buffered tail", "Transfer-Encoding: chunked\r\n\r\nzz\r\ntail"], + ] as const) { + test(`${name} still errors instead of reporting successful completion`, async () => { + await withResponsePeer(peer => peer.end(`HTTP/1.1 200 OK\r\n${wire}`), async response => { + await assert.rejects(response.text(), /expected bytes|invalid chunk terminator|invalid chunk size/); + }); + }); + } + + test("coalesced chunks preserve byte order, extensions, trailers and UTF-8", async () => { + const text = Buffer.from("hello \uD83D\uDE80 \uD55C\uAE00"); + const wire = Buffer.concat([ + Buffer.from("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n7;fixture=yes\r\n"), + text.subarray(0, 7), Buffer.from(`\r\n${(text.length - 7).toString(16)}\r\n`), + text.subarray(7), Buffer.from("\r\n0\r\nX-Fixture: yes\r\n\r\n"), + ]); + await withResponsePeer(peer => peer.end(wire), async response => { + assert.deepEqual(Buffer.from(await response.arrayBuffer()), text); + }); + }); + + for (const format of ["gzip", "deflate"] as const) { + test(`${format} SSE delivers a flushed prefix before the coded body finishes`, async () => { + const prefix = Buffer.from("data: first\n\n"); + const rest = Buffer.from("data: second\n\n"); + const compressor = format === "gzip" ? createGzip() : createDeflate(); + const parts: Buffer[] = []; + compressor.on("data", (value: Buffer) => parts.push(value)); + const finished = new Promise((resolve, reject) => { + compressor.once("end", resolve); + compressor.once("error", reject); + }); + compressor.write(prefix); + await new Promise((resolve, reject) => { + compressor.flush(zlibConstants.Z_SYNC_FLUSH, (error?: Error | null) => error ? reject(error) : resolve()); + }); + const firstCoded = Buffer.concat(parts).length; + compressor.end(rest); + await finished; + const coded = Buffer.concat(parts); + await withResponsePeer(peer => { + peer.write(`HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Encoding: ${format}\r\nContent-Length: ${coded.length}\r\n\r\n`); + peer.write(coded.subarray(0, firstCoded)); + }, async (response, peer) => { + assert.equal(response.headers.has("content-encoding"), false); + assert.equal(response.headers.has("content-length"), false); + const reader = response.body!.getReader(); + try { + const first = await reader.read(); + assert.equal(first.done, false); + assert.deepEqual(Buffer.from(first.value!), prefix); + peer.end(coded.subarray(firstCoded)); + assert.deepEqual(await collectResponse(reader, Number.MAX_SAFE_INTEGER), rest); + } finally { reader.releaseLock(); } + }); + }); + } + + test("abort while waiting for response bytes preserves the Error reason", async () => { + await withResponsePeer(peer => { + peer.write("HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n"); + }, async (response, _peer, closed, abort) => { + const reader = response.body!.getReader(); + const reason = new Error("fixture body cancellation"); + try { + const outcome = reader.read().then(() => assert.fail("unexpected body success"), error => error); + abort.abort(reason); + assert.equal(await outcome, reason); + await closed; + } finally { reader.releaseLock(); } + }); + }); + + test("cancel during an unfinished response releases its peer", async () => { + await withResponsePeer(peer => { + peer.write("HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\nx"); + }, async (response, _peer, closed) => { + const reader = response.body!.getReader(); + try { + assert.equal(Buffer.from((await reader.read()).value!).toString(), "x"); + const pending = reader.read(); + await reader.cancel(); + assert.equal((await pending).done, true); + await closed; + } finally { reader.releaseLock(); } + }); + }); +}); + describe("SOCKS5 TLS handshake lifecycle", () => { test("a stalled TLS handshake reaches an absolute deadline and releases its socket", () => checkHandshakeFailure("deadline")); test("caller cancellation preserves its reason and clears the TLS deadline", () => checkHandshakeFailure("abort")); From 8ebe46b2076445d100cef1e52af4d98d78f8f1e1 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 25 Oct 2026 10:13:30 +0000 Subject: [PATCH 058/101] perf(proxy): bound CONNECT header buffering and avoid repeated prefix copies (#7663) * fix(test): promote Bun 1.4.4 with independent runner role verification * docs: align Docker Bun comments with unified role defaults * test: use a draining TLS origin for early upload replies * test: confine draining origin to buffered HTTP/1 upload * perf(proxy): bound CONNECT header buffering and avoid repeated copies Borrow complete headers, grow partial-header buffers geometrically within the existing cap, and scan only newly received bytes plus delimiter overlap. Preserve early target bytes, strict refusals, TLS verification, cancellation, and single-route no-replay semantics. Extend the registered tunnel tests with copy budgets, split-boundary cases, and real loopback payload preservation. Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --------- Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> Co-authored-by: lidge-jun Co-authored-by: JUN --- src/lib/opaque-proxy-tunnel.ts | 40 +++++-- structure/transports/inventory.md | 6 + tests/lib/opaque-proxy-tunnel.test.ts | 165 +++++++++++++++++++++++++- 3 files changed, 201 insertions(+), 10 deletions(-) diff --git a/src/lib/opaque-proxy-tunnel.ts b/src/lib/opaque-proxy-tunnel.ts index abdeb49ab53..a4187587a00 100644 --- a/src/lib/opaque-proxy-tunnel.ts +++ b/src/lib/opaque-proxy-tunnel.ts @@ -78,7 +78,8 @@ function connectOne(authority: string, proxy: OpaqueProxyRoute, { signal, timeou // Keep a defensive observer across the promise-to-owner handoff. Errors still // settle the establishment promise or are handled by the relay after handoff. socket.on('error', () => {}); - let pending: Buffer = Buffer.alloc(0); let settled = false, tlsReady = !secure; + let pending: Buffer = Buffer.alloc(0), pendingLength = 0; + let owned = false, statusChecked = false, settled = false, tlsReady = !secure; const connectedEvent = secure ? 'secureConnect' : 'connect'; const cleanup = () => { clearTimeout(timer); signal?.removeEventListener('abort', onAbort); @@ -103,22 +104,43 @@ function connectOne(authority: string, proxy: OpaqueProxyRoute, { signal, timeou socket.write(`CONNECT ${authority} HTTP/1.1\r\nHost: ${authority}\r\n${auth}\r\n`); }; const onData = (chunk: Buffer) => { - pending = Buffer.concat([pending, chunk]); - const lineEnd = pending.indexOf('\r\n'); + if (settled || chunk.length === 0) return; + const previousLength = pendingLength; + // Never copy a whole coalesced target payload into the bounded header buffer. + const inputLength = Math.min(chunk.length, maxHeaderBytes - previousLength); + const needed = previousLength + inputLength; + if (previousLength === 0) { + // Most proxies deliver the complete header at once: borrow that chunk. + pending = chunk.subarray(0, inputLength); + } else if (inputLength > 0) { + if (!owned || needed > pending.length) { + const capacity = Math.min(maxHeaderBytes, Math.max(1024, pending.length * 2, needed)); + const grown = Buffer.allocUnsafe(capacity); + pending.copy(grown, 0, 0, previousLength); + pending = grown; + owned = true; + } + chunk.copy(pending, previousLength, 0, inputLength); + } + pendingLength = needed; + const received = pending.subarray(0, pendingLength); + // Only the new suffix and a possible split delimiter need another scan. + const lineEnd = statusChecked ? -1 : received.indexOf('\r\n', Math.max(0, previousLength - 1)); if (lineEnd >= 0) { - const firstLine = pending.subarray(0, lineEnd).toString('latin1'); + statusChecked = true; + const firstLine = received.subarray(0, lineEnd).toString('latin1'); const firstStatus = /^HTTP\/1\.[01] ([0-9]{3})(?: [\t\x20-\x7e\x80-\xff]*)?$/.exec(firstLine); if (!firstStatus) return fail(new TunnelError('PROTOCOL', 'Invalid HTTP proxy status line')); const status = Number(firstStatus[1]); if (status < 200 || status >= 300) return fail(new TunnelError('REFUSED', 'HTTP proxy refused CONNECT', { status })); } - const end = pending.indexOf('\r\n\r\n'); + const end = received.indexOf('\r\n\r\n', Math.max(0, previousLength - 3)); if (end < 0) { - if (pending.length > maxHeaderBytes) fail(new TunnelError('PROTOCOL', 'HTTP proxy response headers exceed limit')); + if (inputLength < chunk.length) fail(new TunnelError('PROTOCOL', 'HTTP proxy response headers exceed limit')); return; } if (end + 4 > maxHeaderBytes) return fail(new TunnelError('PROTOCOL', 'HTTP proxy response headers exceed limit')); - const lines = pending.subarray(0, end).toString('latin1').split('\r\n'); + const lines = received.subarray(0, end).toString('latin1').split('\r\n'); const statusLine = lines[0]; const match = /^HTTP\/1\.[01] ([0-9]{3})(?: [^\r\n]*)?$/.exec(statusLine); if (!match) return fail(new TunnelError('PROTOCOL', 'Invalid HTTP proxy status line')); @@ -128,7 +150,9 @@ function connectOne(authority: string, proxy: OpaqueProxyRoute, { signal, timeou } if (status < 200 || status >= 300) return fail(new TunnelError('REFUSED', 'HTTP proxy refused CONNECT', { status })); settled = true; socket.pause(); cleanup(); - const head = pending.subarray(end + 4); + // unshift is LIFO: restore the outside-cap suffix before the buffered head. + if (inputLength < chunk.length) socket.unshift(chunk.subarray(inputLength)); + const head = received.subarray(end + 4); if (head.length) socket.unshift(head); resolve(socket); }; diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index e5080656d98..6a6cc614f6c 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -473,6 +473,12 @@ are request-local and excluded from diagnostics. HTTPS proxy identity is verifie against its configured host. HTTP Basic usernames with colons and malformed CONNECT response headers are rejected. Header size remains bounded even when a delimiter and early target bytes arrive together; remaining bytes are handed to the caller. +HTTP/HTTPS CONNECT borrows the first response fragment. Additional fragments grow +owned header storage geometrically, capped at `maxHeaderBytes`, so accumulated +prefixes are copied only when storage grows. Delimiter scans examine initialized +bytes from the new suffix plus the split-delimiter overlap. Coalesced target bytes +inside and beyond the header buffer are restored in wire order before handoff. +A complete first-fragment header needs no accumulation copy. `src/lib/socks5-handshake.ts` owns RFC 1928/1929 framing. Both SOCKS spellings use its remote-DNS behavior and existing zero-to-255-byte credential-field limits, including diff --git a/tests/lib/opaque-proxy-tunnel.test.ts b/tests/lib/opaque-proxy-tunnel.test.ts index ed8dd10684a..620509ff863 100644 --- a/tests/lib/opaque-proxy-tunnel.test.ts +++ b/tests/lib/opaque-proxy-tunnel.test.ts @@ -1,7 +1,8 @@ -import { afterEach, test } from "bun:test"; +import { afterEach, spyOn, test } from "bun:test"; import assert from "node:assert/strict"; import net from "node:net"; -import { once } from "node:events"; +import { EventEmitter, once } from "node:events"; +import tls from "node:tls"; import { dialTunnel, type OpaqueProxyRoute } from "../../src/lib/opaque-proxy-tunnel"; import { deadProxy, digest, echo, httpProxy, listen, readBytes, target, TunnelFixtureScope, until } from "../helpers/opaque-proxy-tunnel"; @@ -100,3 +101,163 @@ test("configuration requires one route and a well-formed authority", async () => await assert.rejects(dialTunnel(target, undefined as unknown as OpaqueProxyRoute), { code: "CONFIG" }); assert.equal(proxy.seen.calls, 0); }); + +/** Feed exact transport fragments without relying on OS packet boundaries. */ +class HeaderSocket extends EventEmitter { + destroyed = false; + readonly queued: Buffer[] = []; + readonly writes: string[] = []; + pause() { return this; } + resume() { return this; } + write(bytes: string | Buffer) { this.writes.push(String(bytes)); return true; } + unshift(bytes: Buffer) { this.queued.unshift(bytes); } + destroy() { this.destroyed = true; this.emit("close"); return this; } + feed(bytes: Buffer) { + if (this.destroyed) return; + if (this.listenerCount("data")) this.emit("data", bytes); + else this.queued.push(bytes); + } +} + +function inspectHeaderFragments(chunks: Buffer[], maxHeaderBytes = 65536, secure = false) { + const socket = new HeaderSocket(); + const copies = { concatCalls: 0, concatBytes: 0, copiedBytes: 0, allocations: [] as number[] }; + const originalConcat = Buffer.concat; + const originalCopy = Buffer.prototype.copy; + const originalAlloc = Buffer.allocUnsafe; + let measuring = false; + const connect = secure + ? spyOn(tls, "connect").mockImplementation((options: tls.ConnectionOptions) => { + assert.equal(options.rejectUnauthorized, true); + assert.deepEqual(options.ALPNProtocols, ["http/1.1"]); + assert.equal(typeof options.checkServerIdentity, "function"); + return socket as unknown as tls.TLSSocket; + }) + : spyOn(net, "createConnection").mockImplementation(() => socket as unknown as net.Socket); + const concat = spyOn(Buffer, "concat").mockImplementation((list, length) => { + const result = originalConcat(list, length); + if (measuring) { copies.concatCalls++; copies.concatBytes += result.length; } + return result; + }); + const copy = spyOn(Buffer.prototype, "copy").mockImplementation(function (this: Buffer, target, targetStart, sourceStart, sourceEnd) { + const size = originalCopy.call(this, target, targetStart, sourceStart, sourceEnd); + if (measuring) copies.copiedBytes += size; + return size; + }); + const allocate = spyOn(Buffer, "allocUnsafe").mockImplementation(size => { + if (measuring) copies.allocations.push(size); + return originalAlloc(size); + }); + let pending: Promise; + try { + pending = dialTunnel(target, { host: "127.0.0.1", port: 1, kind: secure ? "https" : "http" }, { maxHeaderBytes }); + // Observe a refusal immediately, while still restoring every spy synchronously. + void pending.catch(() => {}); + socket.emit(secure ? "secureConnect" : "connect"); + measuring = true; + for (const chunk of chunks) socket.feed(chunk); + measuring = false; + } catch (error) { + socket.destroy(); + throw error; + } finally { + measuring = false; + allocate.mockRestore(); copy.mockRestore(); concat.mockRestore(); connect.mockRestore(); + } + return { socket, copies, pending }; +} + +function paddedConnectHeader(size: number): Buffer { + const prefix = "HTTP/1.1 200 Connection Established\r\nX-Fixture: "; + return Buffer.from(prefix + "x".repeat(size - Buffer.byteLength(prefix) - 4) + "\r\n\r\n"); +} + +for (const secure of [false, true]) { + test(`${secure ? "HTTPS" : "HTTP"} CONNECT copies fragmented headers linearly`, async () => { + const header = paddedConnectHeader(65536); + const chunks = Array.from({ length: header.length / 1024 }, (_, index) => header.subarray(index * 1024, (index + 1) * 1024)); + const before = Buffer.from(header); + const fixture = inspectHeaderFragments(chunks, header.length, secure); + await fixture.pending; + assert.deepEqual(header, before, "borrowed input must not be mutated"); + assert.equal(fixture.copies.concatCalls, 0); + assert.ok(fixture.copies.copiedBytes < 3 * header.length, JSON.stringify(fixture.copies)); + assert.ok(fixture.copies.allocations.every(size => size <= header.length)); + assert.equal(fixture.socket.listenerCount("data"), 0); + fixture.socket.destroy(); + }); +} + +test("CONNECT complete headers borrow the original chunk without a copy", async () => { + const header = paddedConnectHeader(64); + const packet = Buffer.concat([header, Buffer.from("opaque-target-banner")]); + const fixture = inspectHeaderFragments([packet], 64); + await fixture.pending; + assert.equal(fixture.copies.concatCalls, 0); + assert.equal(fixture.copies.copiedBytes, 0); + assert.equal(fixture.copies.allocations.length, 0); + assert.deepEqual(Buffer.concat(fixture.socket.queued), packet.subarray(64)); + assert.equal(fixture.socket.queued[0]!.buffer, packet.buffer); + fixture.socket.destroy(); +}); + +test("CONNECT split delimiters and both sides of the header cap preserve opaque bytes", async () => { + const header = paddedConnectHeader(96); + const payload = Buffer.alloc(4096); + for (let index = 0; index < payload.length; index++) payload[index] = index % 251; + const packet = Buffer.concat([header, payload]); + for (const cap of [96, 128, 65536]) { + for (let split = 1; split < header.length; split++) { + const fixture = inspectHeaderFragments([packet.subarray(0, split), Buffer.alloc(0), packet.subarray(split)], cap); + await fixture.pending; + assert.deepEqual(Buffer.concat(fixture.socket.queued), payload, `cap=${cap} split=${split}`); + assert.equal(fixture.socket.listenerCount("data"), 0); + fixture.socket.destroy(); + } + } +}); + +test("CONNECT over-limit input is refused without copying its oversized tail", async () => { + const packet = Buffer.from("HTTP/1.1 200 OK\r\nX-Fixture: " + "x".repeat(1024 * 1024)); + const fixture = inspectHeaderFragments([packet.subarray(0, 8), packet.subarray(8)], 64); + await assert.rejects(fixture.pending, { code: "PROTOCOL" }); + assert.equal(fixture.socket.destroyed, true); + assert.equal(fixture.copies.concatCalls, 0); + assert.ok(fixture.copies.copiedBytes <= 64); + assert.ok(fixture.copies.allocations.every(size => size <= 64)); +}); + +test("CONNECT partial status, refusal, and malformed headers still fail closed", async () => { + for (const [wire, code] of [ + ["HTTP/1.1 407 Refused\r\nUnfinished", "REFUSED"], + ["NOT HTTP\r\n\r\n", "PROTOCOL"], + ["HTTP/1.1 200 OK\r\nInvalidHeader\r\n\r\n", "PROTOCOL"], + ]) { + const bytes = Buffer.from(wire!); + const fixture = inspectHeaderFragments(Array.from(bytes, byte => Buffer.from([byte]))); + await assert.rejects(fixture.pending, { code }); + assert.equal(fixture.socket.destroyed, true); + assert.equal(fixture.socket.listenerCount("data"), 0); + } +}); + +test("CONNECT real socket preserves a large early payload after a split at the header ceiling", async () => { + const header = paddedConnectHeader(128); + const payload = Buffer.alloc(128 * 1024); + for (let index = 0; index < payload.length; index++) payload[index] = index % 251; + const server = net.createServer({ allowHalfOpen: true }, peer => { + let request = ""; + const onData = (bytes: Buffer) => { + request += bytes.toString("latin1"); + if (!request.includes("\r\n\r\n")) return; + peer.removeListener("data", onData); + peer.write(header.subarray(0, header.length - 2)); + scope.later(() => { if (!peer.destroyed) peer.write(Buffer.concat([header.subarray(-2), payload])); }, 5); + }; + peer.on("data", onData); + peer.once("end", () => peer.end()); + }); + const port = await listen(scope, server); + const socket = scope.own(await dialTunnel(target, { host: "127.0.0.1", port }, { maxHeaderBytes: header.length })); + assert.deepEqual(await readBytes(socket, payload.length), payload); +}); From 88f7a77f8498d11ea32ebb35e6657502dc8973a4 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Mon, 26 Oct 2026 04:31:18 +0000 Subject: [PATCH 059/101] fix(protocols): account for Antigravity direct Chat IR (#7724) * fix(protocols): budget Antigravity direct conversations * docs(protocols): note translation-budget charge on the Antigravity direct Chat lane --- docs-site/src/content/docs/guides/protocol-paths.md | 2 +- src/server/chat-completions.ts | 7 +++++++ .../responses/protocol-antigravity-direct-chat.test.ts | 10 ++++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/docs-site/src/content/docs/guides/protocol-paths.md b/docs-site/src/content/docs/guides/protocol-paths.md index 9c79f5699ae..2e5ac6de19c 100644 --- a/docs-site/src/content/docs/guides/protocol-paths.md +++ b/docs-site/src/content/docs/guides/protocol-paths.md @@ -102,7 +102,7 @@ These switches stage the new paths. Each defaults to off except `antigravityDire | `managedMessagesNative` | A Messages request whose route is a direct, key-authenticated Anthropic provider is sent as Messages instead of through the bridge. Routes that need bridge-only behaviour (a pinned effort, blocked-skill elision, the web-search sidecar, vision preprocessing, synthetic rows) stay on the bridge. | | `managedMessagesNativeOAuth` | Native Messages for the unpooled `anthropic` OAuth provider on `api.anthropic.com`. Effective only together with `managedMessagesNative`; a pooled Anthropic OAuth account set stays on the bridge. | | `directEncoders` | For a non-Responses upstream, the answer to a Chat or Messages client is encoded directly from the adapter's events instead of through the internal Responses stream. The request side is unchanged. | -| `antigravityDirect` | **On by default**; set it to `false` to turn it off. A Chat or Messages request whose route is one Google Antigravity provider skips the internal Responses body in both directions: the conversation is decoded from the client body straight into what the Google adapter compiles from, and the answer is encoded directly as with `directEncoders`. The path reads `chat -> ir -> other` or `messages -> ir -> other`. Request settings (model, effort, limits, tool choice) still pass through the usual policies, so account rotation, effort rules and usage are unchanged. Combos, policies, a hosted web-search tool, images for a model marked text-only, a model the shadow-call intercept could rewrite, and text the proxy rewrites (a skills catalog, a generated-image link) stay on the bridge, and the log row names the reason. | +| `antigravityDirect` | **On by default**; set it to `false` to turn it off. A Chat or Messages request whose route is one Google Antigravity provider skips the internal Responses body in both directions: the conversation is decoded from the client body straight into what the Google adapter compiles from, and the answer is encoded directly as with `directEncoders`. The path reads `chat -> ir -> other` or `messages -> ir -> other`. Request settings (model, effort, limits, tool choice) still pass through the usual policies, so account rotation, effort rules and usage are unchanged. Combos, policies, a hosted web-search tool, images for a model marked text-only, a model the shadow-call intercept could rewrite, and text the proxy rewrites (a skills catalog, a generated-image link) stay on the bridge, and the log row names the reason. On a Chat request the decoded conversation counts toward the shared translation budget, so a request whose retained IR exceeds it is refused with HTTP 413 and `translation_buffer_limit` before anything goes upstream. | | `shadowPlan` | At the end of each Chat or Messages request, the plan a preview would have predicted is compared with the path the request took; a disagreement adds `planMismatch: true` to the log row's path record. No second request is sent. | Change them with the CLI or by editing `protocols` in `config.json` diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index e506db2e812..44b30d48664 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -350,6 +350,13 @@ async function handleChatCompletionsWithBudget( // and sends the core only the request settings. if (directRoute) { directConversation = chatConversationToIr(chatBody, chatBody.model as string); + // Unlike the bridge body below, the settings-only projection does not contain the + // conversation. Account for the retained IR itself before an adapter materializes it + // again; reasoning placeholders can be larger than the source Chat message. + translatorBudget.chargeRetained( + Buffer.byteLength(JSON.stringify(directConversation), "utf8"), + { kind: "request_copies" }, + ); internalBody = chatSettingsBody(chatBody); } else { internalBody = chatToResponsesBody(chatBody, { diff --git a/tests/responses/protocol-antigravity-direct-chat.test.ts b/tests/responses/protocol-antigravity-direct-chat.test.ts index a20b4734f4e..3e041ca1d04 100644 --- a/tests/responses/protocol-antigravity-direct-chat.test.ts +++ b/tests/responses/protocol-antigravity-direct-chat.test.ts @@ -231,4 +231,14 @@ describe("Antigravity direct lane through the Chat ingress", () => { expect(trace).toMatchObject({ mode: "legacy-bridge", requestPath: ["chat", "responses-internal", "ir", "other"] }); expect(trace?.reasonCodes).toContain("hosted-tool"); }); + + test("charges the decoded conversation before building the Antigravity request", async () => { + const result = await send(true, false, { + messages: [{ role: "assistant", reasoning_content: "x".repeat(12 * 1024 * 1024), content: "answer" }], + }); + + expect(result.status).toBe(413); + expect(result.text).toContain('"code":"translation_buffer_limit"'); + expect(upstreamBodies).toHaveLength(0); + }); }); From bf0d80c75d0a81e09beb18e2ca7a365dcf5942b4 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Mon, 26 Oct 2026 04:31:45 +0000 Subject: [PATCH 060/101] fix(claude): classify legacy desktop aliases on cold launch (#7721) --- src/cli/claude.ts | 5 +++-- tests/claude-integration/claude-cli.test.ts | 19 ++++++++++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/cli/claude.ts b/src/cli/claude.ts index a50ded6821d..d2d1d65d4dc 100644 --- a/src/cli/claude.ts +++ b/src/cli/claude.ts @@ -32,7 +32,7 @@ import { DEFAULT_CATALOG_PATH } from "../codex/paths"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { aliasForNative, aliasForRoute, legacyAliasForNative, legacyAliasForRoute } from "../claude/alias"; -import { desktop3pAlias, isRealDesktop3pModelId, resolveDesktop3pAlias } from "../claude/desktop-3p"; +import { desktop3pAlias, isRealDesktop3pModelId, legacyDesktop3pAlias, resolveDesktop3pAlias } from "../claude/desktop-3p"; import { desktopProfileWireAlias, legacyDesktopProfileWireAlias } from "../claude/desktop-profile"; import { inspectDesktopFirstParty } from "../claude/desktop-first-party"; import { isClaudeInterceptProxyUrl, type ClaudeInterceptSettingsState } from "../claude/intercept/settings"; @@ -633,7 +633,8 @@ export function nativeClaudeModelIds(config: OcxConfig): string[] { const alias = assignments[route]!.alias; const slash = route.indexOf("/"); return [alias, desktopProfileWireAlias(alias), legacyDesktopProfileWireAlias(alias), - desktop3pAlias(route.slice(0, slash), route.slice(slash + 1))]; + desktop3pAlias(route.slice(0, slash), route.slice(slash + 1)), + legacyDesktop3pAlias(route.slice(0, slash), route.slice(slash + 1))]; })); // The configured default is a genuine declaration too: the catalog seeds it for static // providers, so it must be recognized here even without a server registry. diff --git a/tests/claude-integration/claude-cli.test.ts b/tests/claude-integration/claude-cli.test.ts index 117abbb109a..20774e6524c 100644 --- a/tests/claude-integration/claude-cli.test.ts +++ b/tests/claude-integration/claude-cli.test.ts @@ -17,7 +17,7 @@ import { shouldAllowRootSkipPermissions, } from "../../src/cli/claude"; import { buildClaudeContextWindows } from "../../src/claude/context-windows"; -import { buildDesktop3pRegistry } from "../../src/claude/desktop-3p"; +import { buildDesktop3pRegistry, legacyDesktop3pAlias } from "../../src/claude/desktop-3p"; import { commandInvocation } from "../../src/lib/win-exec"; import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -350,6 +350,23 @@ describe("ocx claude native fallback", () => { .toMatchObject({ flag: ["--model", "opus"] }); }); + test("cold native launch sheds a legacy Desktop alias despite a stale Anthropic declaration", () => { + buildDesktop3pRegistry([], []); + const id = legacyDesktop3pAlias("mock", "model"); + const configured = cfg({ + providers: { anthropic: { adapter: "anthropic", defaultModel: id } }, + claudeCode: { desktopProfile: { version: 1, + assignments: { "mock/model": { family: "opus", alias: "claude-opus-4-8-20260101" } }, + defaults: { opus: "mock/model", fable: null, sonnet: null, haiku: null }, + } }, + }); + + expect(nativeClaudeModelIds(configured)).not.toContain(id); + expect(buildNativeClaudeEnv(configured, { ANTHROPIC_MODEL: id }).ANTHROPIC_MODEL).toBeUndefined(); + expect(nativeModelOverride(id, "opus", [], Object.keys(configured.providers), nativeClaudeModelIds(configured))) + .toMatchObject({ flag: ["--model", "opus"] }); + }); + test("a connected client's window map keeps legacy claude-ocx selectors next to the current ones", () => { const dir = mkdtempSync(join(tmpdir(), "ocx-claude-connected-catalog-")); try { From ef386eb58eef184632cf7796e585fedf9c62a639 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Mon, 26 Oct 2026 04:35:18 +0000 Subject: [PATCH 061/101] fix(scripts): detect diagnostic spawn timeout via exitedDueToTimeout (#7715) Bun.spawnSync returns SyncSubprocess, whose bun-types 1.4.0 declaration has no signal member; exitCode is non-nullable number. The intended check is the declared exitedDueToTimeout flag, which is also correct where POSIX signals do not exist. --- scripts/test-temp-lock.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/test-temp-lock.ts b/scripts/test-temp-lock.ts index 86ad30dea1e..ee83fb254b7 100644 --- a/scripts/test-temp-lock.ts +++ b/scripts/test-temp-lock.ts @@ -78,7 +78,7 @@ const defaultDiagnostic: LockedTempDiagnostic = { return { stdout: decode(result.stdout), stderr: decode(result.stderr), - timedOut: result.signal === "SIGKILL" || result.exitCode === null, + timedOut: result.exitedDueToTimeout === true, }; }, children: targetBasename => listDirectChildren(process.pid, targetBasename), From d056f84926e5b3b2f2c77183a981c09f50df39c2 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Mon, 26 Oct 2026 04:35:35 +0000 Subject: [PATCH 062/101] test(server): isolate port reclaim fixture identity and clock (#7706) * fix(test): promote Bun 1.4.4 with independent runner role verification * docs: align Docker Bun comments with unified role defaults * test: use a draining TLS origin for early upload replies * test: confine draining origin to buffered HTTP/1 upload * test(server): isolate port reclaim fixture identity and clock * test: observe body before buffered early upload reply * test: close direct upload before fixture teardown --------- Co-authored-by: JUN --- tests/server/port-reclaim.test.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/tests/server/port-reclaim.test.ts b/tests/server/port-reclaim.test.ts index 07eccd6a7a1..a3632cc8baf 100644 --- a/tests/server/port-reclaim.test.ts +++ b/tests/server/port-reclaim.test.ts @@ -29,8 +29,14 @@ async function reclaimWithMockClock(options: ReclaimListenPortOptions): Promise< const clock = spyOn(Date, "now").mockImplementation(() => now); try { return await reclaimListenPort(10100, "127.0.0.1", { - ...options, timeoutMs: 50, intervalMs: 10, scanIntervalMs: 10, - sleepMs: async () => { now += 10; }, + ...options, + timeoutMs: options.timeoutMs ?? 50, + intervalMs: options.intervalMs ?? 10, + scanIntervalMs: options.scanIntervalMs ?? 10, + sleepMs: async ms => { + now += ms; + await options.sleepMs?.(ms); + }, }); } finally { clock.mockRestore(); @@ -880,16 +886,17 @@ describe("reclaimListenPort", () => { test("killAllOcxOnPort kills ocx listeners absent from the allowlist snapshot", async () => { const killed: number[] = []; - let holder = 9001; + const holderPid = process.pid === 9001 ? 9002 : 9001; + let holder = holderPid; let available = false; - await expect(reclaimListenPort(10100, "127.0.0.1", { + await expect(reclaimWithMockClock({ timeoutMs: 200, intervalMs: 20, scanIntervalMs: 20, dropTcpRows: false, killOcxHolders: true, killAllOcxOnPort: true, - onlyKillPids: [100], // pre-update PID — respawned child is 9001 + onlyKillPids: [100], // pre-update PID — the respawned holder is deliberately not self isAvailableFn: async () => available, listListenPidsFn: () => (holder > 0 ? [holder] : []), isAliveFn: pid => pid === holder, @@ -902,7 +909,7 @@ describe("reclaimListenPort", () => { if (holder === 0) available = true; }, })).resolves.toBe(true); - expect(killed).toEqual([9001]); + expect(killed).toEqual([holderPid]); }); test("foreign non-ocx claimant survives reclaim without allowlist or ocx identity", async () => { From ea48448c24f789b088143946aca2e6a567c71e7c Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Mon, 26 Oct 2026 04:41:38 +0000 Subject: [PATCH 063/101] fix(management): declare Hermes profile route (#7729) --- src/server/management/route-registry.ts | 3 +++ tests/server/management-route-registry.test.ts | 11 +++++++++++ 2 files changed, 14 insertions(+) diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index 4dcb19d7167..2172c08abe8 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -53,6 +53,8 @@ export type ExemptionReason = | "dead" /** Desktop shell internal display-state POST; the CLI has no app updater state to publish. */ | "desktop-internal" + /** Dashboard-only discovery for an interaction the CLI does not expose. */ + | "gui-only" /** * A verb is owed but belongs to a later work-phase. BOUNDED: requires `owner` and * `ownerDoc`, and the parity test asserts that tracked doc exists and names the route. @@ -445,6 +447,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "GET", path: "/api/system/codex-app-server", module: "server/management/system-routes", mutates: false, mechanism: "path-constant" }, { method: "POST", path: "/api/system/codex-restart", module: "server/management/system-routes", mutates: true, mechanism: "path-constant" }, { method: "POST", path: "/api/providers/reload", module: "server/management/provider-routes", mutates: true, mechanism: "path-constant", exempt: { reason: "capability-principal", why: "Gated on the local-provider-reload-capability principal (provider-routes.ts:467), not an operator action." } }, + { method: "GET", path: "/api/client-integrations/hermes/profiles", module: "server/management/hermes-profile-routes", mutates: false, mechanism: "path-constant", exempt: { reason: "gui-only", why: "The dashboard discovers selectable Hermes profiles; the CLI intentionally has no Hermes profile selector." } }, { method: "GET", path: "/api/client-integrations/{clientId}", module: "server/management/integration-routes", mutates: false, mechanism: "prefix-decode" }, { method: "PUT", path: "/api/client-integrations/{clientId}", module: "server/management/integration-routes", mutates: true, mechanism: "prefix-decode" }, { method: "GET", path: "/api/request-history/{id}", module: "server/management/request-history-routes", mutates: false, mechanism: "slice" }, diff --git a/tests/server/management-route-registry.test.ts b/tests/server/management-route-registry.test.ts index ff380295506..3e6c86e5ff0 100644 --- a/tests/server/management-route-registry.test.ts +++ b/tests/server/management-route-registry.test.ts @@ -64,6 +64,17 @@ describe("management route registry reconciliation", () => { .toMatchObject({ module: "server/management/oauth-account-routes", mutates: false, exempt: { reason: "session-only" } }); }); + test("the constant-backed Hermes profile list is explicitly declared", () => { + expect(MANAGEMENT_ROUTES.find(route => + route.method === "GET" && route.path === "/api/client-integrations/hermes/profiles")) + .toMatchObject({ + module: "server/management/hermes-profile-routes", + mutates: false, + mechanism: "path-constant", + exempt: { reason: "gui-only" }, + }); + }); + test("every route resolvable from source is declared in the registry", () => { const declared = new Set(MANAGEMENT_ROUTES.map(r => key(r.method, r.path))); const undeclared: string[] = []; From 01ec325d1f39ec104150174688a486f10a56545d Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Mon, 26 Oct 2026 04:45:25 +0000 Subject: [PATCH 064/101] fix(config): preserve disabled sidecars when targets retire (#7420) * fix(config): preserve disabled sidecars when targets retire * fix(sidecar): preserve activation through model edits and drain test fixtures * fix(gui): confirm only sidecar edits included in a save * fix(test): promote Bun 1.4.4 with independent runner role verification * docs: align Docker Bun comments with unified role defaults * test: use a draining TLS origin for early upload replies * test: confine draining origin to buffered HTTP/1 upload --------- Co-authored-by: Epinephrine <27862058+luvs01@users.noreply.github.com> --- docs-site/src/content/docs/guides/sidecars.md | 5 + gui/src/pages/claude-code-save.ts | 28 +- gui/src/pages/claude-code-sidecar.ts | 35 +- gui/src/pages/claude-manual-env.ts | 2 +- gui/tests/claude-code-save.test.ts | 68 +++ gui/tests/claude-code-sidecar.test.ts | 26 ++ src/config/removed-opencode-free.ts | 11 + .../management/agent-settings-routes.ts | 10 +- src/types/config.ts | 4 +- structure/config.md | 2 +- structure/gui-and-management-api.md | 2 +- .../config-removed-opencode-free.test.ts | 112 ++++- ...agement-sidecar-anthropic-instance.test.ts | 133 +++++- tests/vision/mistral-vision-sidecar.test.ts | 397 ++++++++++++++++-- 14 files changed, 765 insertions(+), 70 deletions(-) diff --git a/docs-site/src/content/docs/guides/sidecars.md b/docs-site/src/content/docs/guides/sidecars.md index 4df1c0cfcf7..e25d49dbfe7 100644 --- a/docs-site/src/content/docs/guides/sidecars.md +++ b/docs-site/src/content/docs/guides/sidecars.md @@ -18,6 +18,11 @@ closed. Explicit `xai` requires a usable stored Grok OAuth account and does not requires both ChatGPT login auth and an enabled `forward` provider. ::: +On file load, a retired OpenCode Free model target in global or `claudeCode` overrides of +`visionSidecar` or `webSearchSidecar` is removed and that sidecar is explicitly disabled, retaining +its other settings. Select a supported target before re-enabling it. Healthy targets and the defaults +for absent sidecar sections are unchanged. + ### Additional web-search backends (explicit-only) Three more web-search backends exist beyond the ChatGPT and Claude paths. Each is diff --git a/gui/src/pages/claude-code-save.ts b/gui/src/pages/claude-code-save.ts index 58e938502f2..f494c14fbc0 100644 --- a/gui/src/pages/claude-code-save.ts +++ b/gui/src/pages/claude-code-save.ts @@ -1,4 +1,4 @@ -import { serializeSidecarOverride } from "./claude-code-sidecar"; +import { confirmSidecarOverride, serializeSidecarOverride } from "./claude-code-sidecar"; import type { ClaudeCodeState, MapRow } from "./claude-code-types"; import { newClientId } from "./claude-code-types"; @@ -118,7 +118,10 @@ export function applyServerRead(current: ClaudeCodeEditState | null, next: Claud */ export function savedCopy(serverState: ClaudeCodeState, submitted: ClaudeCodeEditable): ClaudeCodeEditable { return { - state: { ...serverState, ...pickEditable(submitted.state) }, + state: { ...serverState, ...pickEditable(submitted.state), + visionSidecar: confirmSidecarOverride(submitted.state.visionSidecar), + webSearchSidecar: confirmSidecarOverride(submitted.state.webSearchSidecar), + }, rows: Object.entries(normalizedModelMap(submitted.rows)).map(([from, to]) => ({ id: newClientId(), from, to })), }; } @@ -136,6 +139,23 @@ export function acknowledgeSave(current: ClaudeCodeEditState | null, submitted: const baseline = savedCopy(current.baseline.state, submitted); const rows = baseline.rows; const unchanged = claudeCodeDraftKey(current.draft) === claudeCodeDraftKey(submitted); - // Untouched since submission: show what was stored. Edited meanwhile: keep the edits, dirty. - return { draft: unchanged ? { state: current.draft.state, rows } : current.draft, baseline, adoptNextRead: unchanged }; + if (unchanged) { + // Untouched since submission: show what was stored. + return { draft: { state: { ...current.draft.state, + visionSidecar: baseline.state.visionSidecar, webSearchSidecar: baseline.state.webSearchSidecar, + }, rows }, baseline, adoptNextRead: true }; + } + /* + * Edited while the PUT was out: keep the edits, dirty. A sidecar is still confirmed — + * ending its draft-only activation provenance — when its serialized shape is exactly what + * the Save stored; one edited after submission keeps its marker until its own save lands. + */ + let draft = current.draft; + for (const key of ["visionSidecar", "webSearchSidecar"] as const) { + const stored = JSON.stringify(serializeSidecarOverride(submitted.state[key])); + if (JSON.stringify(serializeSidecarOverride(draft.state[key])) !== stored) continue; + const confirmed = confirmSidecarOverride(draft.state[key]); + if (confirmed !== draft.state[key]) draft = { ...draft, state: { ...draft.state, [key]: confirmed } }; + } + return { draft, baseline, adoptNextRead: false }; } diff --git a/gui/src/pages/claude-code-sidecar.ts b/gui/src/pages/claude-code-sidecar.ts index e21c537fc58..6480a48db4a 100644 --- a/gui/src/pages/claude-code-sidecar.ts +++ b/gui/src/pages/claude-code-sidecar.ts @@ -1,16 +1,29 @@ /** * Claude Code sidecar override helpers — keep Auto as an in-memory draft so * users can enter a model before persist. Empty Auto persists only when it - * carries an explicit pool edit. + * carries an explicit activation state or pool edit. */ import type { AnthropicInstanceId, SidecarOverride, VisionOverrideBackend } from "./claude-manual-env"; export type SidecarSelectValue = "inherit" | "auto" | VisionOverrideBackend; +// Draft-only provenance follows object spreads but cannot enter JSON or the PUT shape. +const modelEditStartedDisabled = Symbol("modelEditStartedDisabled"); +type SidecarDraft = SidecarOverride & { [modelEditStartedDisabled]?: true }; + +/** A confirmed save starts a new editing baseline; drafts without a pending marker keep their identity. */ +export function confirmSidecarOverride(override?: SidecarOverride): SidecarOverride | undefined { + if (!override || !(override as SidecarDraft)[modelEditStartedDisabled]) return override; + const next = { ...override } as SidecarDraft; + delete next[modelEditStartedDisabled]; + return next; +} + export type PersistedSidecarOverride = { - backend: VisionOverrideBackend | null; - model: string; + enabled?: boolean; + backend?: VisionOverrideBackend | null; + model?: string; anthropicInstance?: AnthropicInstanceId | null; }; @@ -38,28 +51,32 @@ export function applySidecarPoolChange(override: SidecarOverride | undefined, va return next; } -/** Model typing updates the draft in place; empty Auto stays selectable until save. */ +/** Model edits preserve activation unless this draft started disabled. */ export function applySidecarModelChange( override: SidecarOverride | undefined, model: string, ): SidecarOverride { - return { ...override, model }; + const startedDisabled = override?.enabled === false || (override as SidecarDraft | undefined)?.[modelEditStartedDisabled]; + return { ...override, model, ...(startedDisabled ? { + enabled: model.trim().length > 0, [modelEditStartedDisabled]: true as const, + } : {}) }; } /** * Persist shape for PUT /api/claude-code. - * Empty Auto drafts ({ backend: undefined } / blank model) become null → reload as Inherit. + * Empty Auto drafts become null → Inherit unless an explicit activation state must survive. */ export function serializeSidecarOverride( override?: SidecarOverride, ): PersistedSidecarOverride | null { if (!override) return null; const trimmed = (override.model ?? "").trim(); + const activation = override.enabled === undefined ? {} : { enabled: override.enabled }; if (!override.backend) { - if (!trimmed && override.anthropicInstance === undefined) return null; - return { backend: null, model: trimmed, + if (!trimmed && override.anthropicInstance === undefined) return override.enabled === undefined ? null : { ...activation, backend: null, model: "" }; + return { ...activation, backend: null, model: trimmed, ...(override.anthropicInstance !== undefined ? { anthropicInstance: override.anthropicInstance } : {}) }; } - return { backend: override.backend, model: trimmed, + return { ...activation, backend: override.backend, model: trimmed, ...(override.anthropicInstance !== undefined ? { anthropicInstance: override.anthropicInstance } : {}) }; } diff --git a/gui/src/pages/claude-manual-env.ts b/gui/src/pages/claude-manual-env.ts index 3c44f02c7b4..b5a2d446991 100644 --- a/gui/src/pages/claude-manual-env.ts +++ b/gui/src/pages/claude-manual-env.ts @@ -18,7 +18,7 @@ export interface AnthropicPoolOptions { available: AnthropicInstanceId[]; code?: "anthropic_helper_unavailable"; } -export interface SidecarOverride { backend?: VisionOverrideBackend; model?: string; anthropicInstance?: AnthropicInstanceId | null } +export interface SidecarOverride { enabled?: boolean; backend?: VisionOverrideBackend; model?: string; anthropicInstance?: AnthropicInstanceId | null } export interface ClaudeManualEnvState { /** diff --git a/gui/tests/claude-code-save.test.ts b/gui/tests/claude-code-save.test.ts index 1c71930f306..9d95622d821 100644 --- a/gui/tests/claude-code-save.test.ts +++ b/gui/tests/claude-code-save.test.ts @@ -10,6 +10,8 @@ import { type ClaudeCodeEditable, } from "../src/pages/claude-code-save"; import { normalizeContextAccounting, type ClaudeCodeState, type MapRow } from "../src/pages/claude-code-types"; +import { applySidecarBackendChange, applySidecarModelChange, applySidecarPoolChange } from "../src/pages/claude-code-sidecar"; +import type { SidecarOverride } from "../src/pages/claude-manual-env"; const STATE = { enabled: true, @@ -38,6 +40,26 @@ const STATE = { const row = (from: string, to: string, id = `${from}->${to}`): MapRow => ({ id, from, to }); const editable = (state: Partial = {}, rows: MapRow[] = []): ClaudeCodeEditable => ({ state: { ...STATE, ...state }, rows }); +test("model-edit activation provenance survives drafts but ends at a confirmed save or revert", () => { + const base = editable({ visionSidecar: { enabled: false } }); + const typed = applySidecarModelChange(base.state.visionSidecar, "gpt-5.6-luna"); + const submitted = editable({ visionSidecar: applySidecarPoolChange(applySidecarBackendChange(typed, "openai"), "") }); + const body = claudeCodeSaveBody(submitted.state, []); + expect(body.visionSidecar).toEqual({ enabled: true, backend: "openai", model: "gpt-5.6-luna" }); + expect(Object.getOwnPropertySymbols(body.visionSidecar!)).toHaveLength(0); + expect(JSON.parse(JSON.stringify(body)).visionSidecar).toEqual(body.visionSidecar); + const polled = applyServerRead({ draft: submitted, baseline: base, adoptNextRead: false }, base); + expect(applySidecarModelChange(polled.draft.state.visionSidecar, "").enabled).toBe(false); + expect(revertEditable(submitted.state, base.state).visionSidecar).toEqual({ enabled: false }); + const acked = acknowledgeSave({ draft: submitted, baseline: base, adoptNextRead: false }, submitted)!; + // Even if the acknowledging GET fails, the successful PUT is the new activation baseline. + expect(applySidecarModelChange(acked.draft.state.visionSidecar, "").enabled).toBe(true); + const reverted = revertEditable(base.state, acked.baseline.state); + expect(applySidecarModelChange(reverted.visionSidecar, "").enabled).toBe(true); + const fresh = editable({ visionSidecar: { enabled: true, backend: "openai", model: "gpt-5.6-luna" } }); + expect(applySidecarModelChange(applyServerRead(acked, fresh).draft.state.visionSidecar, "").enabled).toBe(true); +}); + test("tier defaults participate in save, polling preservation and revert", () => { const tiers = { opus: "combo/opus", sonnet: "combo/sonnet", haiku: "combo/haiku" }; const draft = editable({ tierModels: tiers }); @@ -148,3 +170,49 @@ test("context accounting is saved, dirties the draft, and older states read as t expect(normalizeContextAccounting("bogus")).toBe("1m"); expect(normalizeContextAccounting("200k")).toBe("200k"); }); + +const sidecarTyped = (disabled: SidecarOverride | undefined, model: string) => + applySidecarBackendChange(applySidecarModelChange(disabled, model), "openai"); + +test("a stored sidecar is confirmed even when an unrelated field was edited while the PUT was out", () => { + const base = editable({ visionSidecar: { enabled: false }, webSearchSidecar: { enabled: false } }); + const vision = sidecarTyped(base.state.visionSidecar, "gpt-5.6-luna"); + const web = sidecarTyped(base.state.webSearchSidecar, "claude-haiku-4-5"); + const submitted = editable({ visionSidecar: vision, webSearchSidecar: web }); + // Only an unrelated field changed mid-flight; the sidecars the server stored are unchanged. + const pending = editable({ visionSidecar: vision, webSearchSidecar: web, authMode: "proxy" }); + const acked = acknowledgeSave({ draft: pending, baseline: base, adoptNextRead: false, savePending: true }, submitted)!; + expect(acked.adoptNextRead).toBe(false); + expect(acked.draft.state.authMode).toBe("proxy"); + // Confirmed enabled=true: clearing the model afterwards keeps the saved activation. + expect(applySidecarModelChange(acked.draft.state.visionSidecar, "").enabled).toBe(true); + expect(applySidecarModelChange(acked.draft.state.webSearchSidecar, "").enabled).toBe(true); +}); + +test("a sidecar edited after submission keeps its disabled-start marker until its own save", () => { + const base = editable({ visionSidecar: { enabled: false } }); + const typed = sidecarTyped(base.state.visionSidecar, "gpt-5.6-luna"); + const submitted = editable({ visionSidecar: typed }); + // Cleared mid-flight: the serialized shape no longer matches what the PUT stored. + const cleared = applySidecarModelChange(typed, ""); + const acked = acknowledgeSave({ draft: editable({ visionSidecar: cleared }), baseline: base, adoptNextRead: false }, submitted)!; + // The post-submit edit itself is preserved, not overwritten by the confirmed copy. + expect(acked.draft.state.visionSidecar).toBe(cleared); + expect(isClaudeCodeDraftDirty(acked.draft, acked.baseline)).toBe(true); + // Retyping and clearing again still returns to disabled: this draft never reached the server. + expect(applySidecarModelChange(applySidecarModelChange(acked.draft.state.visionSidecar, "gpt-5.6-luna"), "").enabled).toBe(false); +}); + +test("stored and post-submit-edited sidecars are confirmed independently", () => { + const base = editable({ visionSidecar: { enabled: false }, webSearchSidecar: { enabled: false } }); + const vision = sidecarTyped(base.state.visionSidecar, "gpt-5.6-luna"); + const web = sidecarTyped(base.state.webSearchSidecar, "claude-haiku-4-5"); + const submitted = editable({ visionSidecar: vision, webSearchSidecar: web }); + // Vision's model changed mid-flight (still enabled), webSearch did not: each is decided on its own. + const visionRetyped = applySidecarModelChange(vision, "gpt-5.6-astra"); + const acked = acknowledgeSave({ draft: editable({ visionSidecar: visionRetyped, webSearchSidecar: web }), baseline: base, adoptNextRead: false }, submitted)!; + expect(applySidecarModelChange(acked.draft.state.webSearchSidecar, "").enabled).toBe(true); + // Vision stays enabled in the draft, so only its retained marker turns a clear back into disabled. + expect(acked.draft.state.visionSidecar).toBe(visionRetyped); + expect(applySidecarModelChange(acked.draft.state.visionSidecar, "").enabled).toBe(false); +}); diff --git a/gui/tests/claude-code-sidecar.test.ts b/gui/tests/claude-code-sidecar.test.ts index 71f450d0a4a..4059f47d527 100644 --- a/gui/tests/claude-code-sidecar.test.ts +++ b/gui/tests/claude-code-sidecar.test.ts @@ -32,6 +32,20 @@ test("Empty Auto serializes as null (reloads as Inherit)", () => { expect(serializeSidecarOverride({})).toBeNull(); }); +test("disabled intent survives untouched saves and backend drafts until explicit reset or model choice", () => { + const disabled = { enabled: false }; + expect(serializeSidecarOverride(disabled)).toEqual({ enabled: false, backend: null, model: "" }); + expect(serializeSidecarOverride({ enabled: true })).toEqual({ enabled: true, backend: null, model: "" }); + expect(serializeSidecarOverride(applySidecarBackendChange(disabled, "auto"))).toEqual({ enabled: false, backend: null, model: "" }); + expect(serializeSidecarOverride(applySidecarBackendChange(disabled, "inherit"))).toBeNull(); + expect(serializeSidecarOverride(applySidecarModelChange(disabled, "gpt-5.6-luna"))) + .toEqual({ enabled: true, backend: null, model: "gpt-5.6-luna" }); + const typedThenCleared = applySidecarModelChange(applySidecarModelChange(disabled, "gpt-5.6-luna"), " "); + expect(serializeSidecarOverride(typedThenCleared)).toEqual({ enabled: false, backend: null, model: "" }); + expect(serializeSidecarOverride({ enabled: false, backend: "openai", model: "gpt-5.6-luna" })) + .toEqual({ enabled: false, backend: "openai", model: "gpt-5.6-luna" }); +}); + test("Switching back to Inherit clears the override", () => { const withModel = applySidecarModelChange( applySidecarBackendChange(undefined, "auto"), @@ -42,6 +56,18 @@ test("Switching back to Inherit clears the override", () => { expect(serializeSidecarOverride(undefined)).toBeNull(); }); +test("clearing a model preserves an already-enabled or implicit override", () => { + for (const enabled of [true, undefined]) { + const initial = { enabled, backend: "openai" as const, model: "gpt-5.6-luna" }; + const cleared = applySidecarModelChange(initial, " "); + expect(serializeSidecarOverride(cleared)).toEqual({ + ...(enabled === undefined ? {} : { enabled }), backend: "openai", model: "", + }); + expect(serializeSidecarOverride(applySidecarModelChange(applySidecarModelChange(initial, "replacement"), ""))) + .toEqual(serializeSidecarOverride(cleared)); + } +}); + test("Switching Auto ↔ OpenAI ↔ Anthropic preserves the model", () => { let override = applySidecarModelChange( applySidecarBackendChange(undefined, "auto"), diff --git a/src/config/removed-opencode-free.ts b/src/config/removed-opencode-free.ts index e308c348223..9c6c146cf24 100644 --- a/src/config/removed-opencode-free.ts +++ b/src/config/removed-opencode-free.ts @@ -95,6 +95,17 @@ export function sanitizeRemovedOpenCodeFreeForLoad(parsed: unknown): void { delete config.syncCodexSubagentDefaults; delete config.injectionEffort; } + // These sidecars default on or inherit global settings when absent. Preserve the + // off switch at both supported owners before pruning their retired model target. + for (const owner of [config, record(config.claudeCode)]) { + for (const key of ["visionSidecar", "webSearchSidecar"]) { + const sidecar = record(owner?.[key]); + if (!sidecar || !retired(sidecar.model)) continue; + sidecar.enabled = false; + delete sidecar.model; + changed = true; + } + } for (const [key, value] of Object.entries(config)) { if (key === "providers" || !/model|provider|combo|routing|claudeCode|fallback|redirect|compaction|autoReview|advisor|tokenGuardian|images|codexRoleTiers|sidecar|shadowCallIntercept/i.test(key)) continue; const next = prune(value, key === "claudeCode"); diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index 5cfd3133c98..8ea546b1177 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -1516,10 +1516,10 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise visionSidecar: sidecarAnthropicPoolOptions(config, { ...helperVision, backend: helperVisionBackend }, helperParent), }, ...(webSearchOverride && Object.keys(webSearchOverride).length > 0 - ? { webSearchSidecar: { backend: webSearchOverride.backend, model: webSearchOverride.model, ...(webSearchOverride.anthropicInstance ? { anthropicInstance: webSearchOverride.anthropicInstance } : {}) } } + ? { webSearchSidecar: { enabled: webSearchOverride.enabled, backend: webSearchOverride.backend, model: webSearchOverride.model, ...(webSearchOverride.anthropicInstance ? { anthropicInstance: webSearchOverride.anthropicInstance } : {}) } } : {}), ...(visionOverride && Object.keys(visionOverride).length > 0 - ? { visionSidecar: { backend: visionOverride.backend, model: visionOverride.model, ...(visionOverride.anthropicInstance ? { anthropicInstance: visionOverride.anthropicInstance } : {}) } } + ? { visionSidecar: { enabled: visionOverride.enabled, backend: visionOverride.backend, model: visionOverride.model, ...(visionOverride.anthropicInstance ? { anthropicInstance: visionOverride.anthropicInstance } : {}) } } : {}), fastMode: config.fastMode, forceAvailable: [...visibleNativeSlugs(config), ...filterCatalogVisibleModels(models, config).map(catalogModelSlug)], @@ -1673,6 +1673,9 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise const section = body[field]; if (section === undefined || section === null) continue; if (!isPlainObject(section)) return jsonResponse({ error: `${field} must be an object or null` }, 400); + if (section.enabled !== undefined && typeof section.enabled !== "boolean") { + return jsonResponse({ error: `${field}.enabled must be a boolean` }, 400); + } // Both overrides now speak their full unions (roadmap 060 web, 170 // vision revised). Vision's third arm is "routed" (loopback through the // proxy's own router), never exa: exa is not an LLM, and accepting an @@ -1760,8 +1763,9 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise } // The per-field validation above guarantees vision only ever carries the two-member // union; the cast is the loop's shared-shape compromise, not a wider write path. - const requested = section as { backend?: "openai" | "anthropic" | "xai" | "gemini" | "exa" | null; model?: string; anthropicInstance?: AnthropicInstanceId | null }; + const requested = section as { enabled?: boolean; backend?: "openai" | "anthropic" | "xai" | "gemini" | "exa" | null; model?: string; anthropicInstance?: AnthropicInstanceId | null }; const override = { ...next[field] } as NonNullable; + if (requested.enabled !== undefined) override.enabled = requested.enabled; if (requested.anthropicInstance === null) delete override.anthropicInstance; else if (isAnthropicInstanceId(requested.anthropicInstance)) override.anthropicInstance = requested.anthropicInstance; if (requested.backend === null) delete override.backend; diff --git a/src/types/config.ts b/src/types/config.ts index 479895aa2c4..fedcf82a440 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -234,9 +234,9 @@ export interface OcxClaudeCodeConfig { /** Optional roster-style model forced on subagents at the next routed Claude Code launch. */ subagentModelForce?: string; /** Claude-originated web-search override. Unset fields inherit the global sidecar settings. */ - webSearchSidecar?: { backend?: "openai" | "anthropic" | "xai" | "gemini" | "exa"; model?: string; anthropicInstance?: AnthropicInstanceId }; + webSearchSidecar?: { enabled?: boolean; backend?: "openai" | "anthropic" | "xai" | "gemini" | "exa"; model?: string; anthropicInstance?: AnthropicInstanceId }; /** Claude-originated vision override. Unset fields inherit the global sidecar settings. */ - visionSidecar?: { backend?: "openai" | "anthropic" | "routed"; model?: string; anthropicInstance?: AnthropicInstanceId }; + visionSidecar?: { enabled?: boolean; backend?: "openai" | "anthropic" | "routed"; model?: string; anthropicInstance?: AnthropicInstanceId }; /** Persisted Claude Desktop four-family routing profile. */ desktopProfile?: OcxClaudeDesktopProfile; /** diff --git a/structure/config.md b/structure/config.md index 68a9d008892..9ed99cee5cc 100644 --- a/structure/config.md +++ b/structure/config.md @@ -27,7 +27,7 @@ The Codex restart command follows the [CLI restart scope contract](runtime.md#cl the [source-owned credential contract](codex-home.md#orca-source-owned-account-import) governs deduplication and credential storage separately from Codex config injection. See [permanent refusal auto-pause](account-auto-pause.md). ## Config surface -`src/config/removed-opencode-free.ts` disables retired OpenCode Free provider entries (name, type or preset identity), their model references, and empty dependent combos/profiles on file load. It warns once per process about the upstream 403 gate (#7255), preserves file bytes, and restores the standard OpenAI OAuth default when the retired provider was the default. Healthy targets and unrelated settings remain intact. Retired provider and route aliases are trimmed before reference matching, including role tiers, sidecars, shadow interception and provider review selectors. Desktop assignment pruning repairs each affected family default to its first sorted remaining route or null; healthy defaults and applied markers survive. Depleted source-model allowlists disable their rule, and removing an injection model also removes its effort and native subagent sync. `src/config/live-reconcile.ts` sanitizes its authoritative raw snapshot before Claude hand-edit comparison so unrelated saves cannot resurrect removed references; healthy disk edits still win. Candidate writes retain normal validation. Custom Responses providers may declare independent boolean `supportsNativeCompactionTrigger` and `supportsNativeCompactEndpoint` capabilities in `src/config/schema/leaf-validators.ts`; absence remains off. They do not imply `decodesNativeCompactionBlobs`, official identity, or credential authority; custom native v1 key/local/forward replay enforces the separate blob capability and known serving-identity changes, while OAuth retains routed credential resolution. The native gateway contract and regression are in [Responses transport](transports/responses.md) and `tests/responses/native-compaction-gateway.test.ts`. +`src/config/removed-opencode-free.ts` disables retired OpenCode Free provider entries (name, type or preset identity), their model references, and empty dependent combos/profiles on file load. It warns once per process about the upstream 403 gate (#7255), preserves file bytes, and restores the standard OpenAI OAuth default when the retired provider was the default. Healthy targets and unrelated settings remain intact. Retired provider and route aliases are trimmed before reference matching, including role tiers, sidecars, shadow interception and provider review selectors. Desktop assignment pruning repairs each affected family default to its first sorted remaining route or null; healthy defaults and applied markers survive. Depleted source-model allowlists disable their rule, and removing an injection model also removes its effort and native subagent sync. `src/config/live-reconcile.ts` sanitizes its authoritative raw snapshot before Claude hand-edit comparison so unrelated saves cannot resurrect removed references; healthy disk edits still win. Candidate writes retain normal validation. Custom Responses providers may declare independent boolean `supportsNativeCompactionTrigger` and `supportsNativeCompactEndpoint` capabilities in `src/config/schema/leaf-validators.ts`; absence remains off. They do not imply `decodesNativeCompactionBlobs`, official identity, or credential authority; custom native v1 key/local/forward replay enforces the separate blob capability and known serving-identity changes, while OAuth retains routed credential resolution. The native gateway contract and regression are in [Responses transport](transports/responses.md) and `tests/responses/native-compaction-gateway.test.ts`. Retired model targets in global or `claudeCode` overrides of `visionSidecar` and `webSearchSidecar` leave `enabled: false` with healthy sibling settings intact, whether enabled was false, true or omitted. Healthy sidecar targets retain their configured activation state; absent sections retain the normal defaults. `src/config/schema/config-schema.ts` validates the optional `preferModelAliasIds` flag as a boolean; malformed disk values disable this raw-list projection without rejecting providers. It defaults off. Custom Responses providers can explicitly enable `preserveResponsesInputItemIds` and `preserveResponsesMessageMetadata`; both default off and accept only booleans. The [replay sanitization contract](transports/responses-wire-shapes.md) keeps ordinary destinations unchanged and preserves xAI custom-call ID repair. `src/config/schema/compaction-recovery.ts` strictly validates opt-in `compactionRecovery`; invalid disk values disable it with a warning, while candidate writes reject them. `src/config/schema/blocked-model-redirects.ts` applies the same read-degrade/write-reject boundary to malformed `blockedModelRedirects` maps. The [failure-only contract](transports/responses-failover.md) leaves provider identity, accounts and client compaction unchanged. Optional `desktopCompatibility: { startOnProxyStart: boolean }` likewise rejects invalid writes and degrades invalid disk values to off; it saves neither certificate consent nor correction mode. Its field-scoped dashboard writer adopts committed state into the live comparison baseline; see [Codex Desktop startup](clients/codex-desktop.md#proxy-startup-preference). `src/cli/config-command.ts` accepts one leading UTF-8 BOM when parsing validate/import input from a file or stdin. JSON syntax and schema validation still run before persistence; BOM characters inside string values remain data. `skills.catalog_refresh` in the proxy JSON configuration accepts `per_session` (the runtime default when absent) or `per_turn`. The former retains received skills instructions for a conversation; the latter passes through the current catalog. This is separate from Codex's `skills.include_instructions` TOML switch and does not change the live dashboard probe. See the [Responses snapshot contract](transports/responses.md#responses-httpsse). Optional `reasoningRetention` is advanced and off by default: only explicit boolean `enabled: true` activates it, so omitted/false/malformed and legacy limit-only blocks stay off. When enabled, it accepts `maxContextPercent` (>0, <=100; default 20) and `maxTokens` (positive integer; default 100000), using the smaller limit for [routed v1/v2 compaction](transports/responses-failover.md#compaction-routing-overrides). An unknown context window uses the absolute cap only. Malformed disk values degrade only this block with a value-free warning; candidate writes reject them before the load schema's catch. Absence remains unpersisted, and native/opaque protocols are unchanged. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 44729c1de4a..8eacfda6774 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -293,7 +293,7 @@ per-request first-party callback reads that live object; a failed write leaves i | Provider quotas and tests | `src/server/management/provider-routes.ts` — `GET /api/provider-quotas`, `POST /api/providers/test`, `POST /api/providers/test-completion` (explicit single-model completion diagnostic, [contract](provider-completion-diagnostic.md)), `GET/PUT /api/provider-context-caps`, `GET /api/provider-presets`. A quota read may be served from cache or force-refreshed; absent quota data is reported as unknown rather than as a measured zero. `GET /api/quota-pool` (`src/server/management/quota-pool-routes.ts`, loaded on demand) is the always-passive `ocx.quota-pool/1` read GOAT consumes; see [quota pool](quota-pool.md). | | Models and visibility | `src/server/management/model-routes.ts` — `GET /api/models`, `PUT /api/disabled-models`, `PUT /api/model-visibility`, `PUT /api/selected-models`, `GET/POST /api/custom-models`, `PUT /api/model-settings`. Visibility writes trigger catalog sync through the owning server path. `PUT /api/model-settings` edits one routed row's capability axes in place — context window, declared input modalities, reasoning ladder and its default — through the provider-level per-model maps the runtime already reads, so the row keeps its discovery provenance instead of being replaced by a custom model. Every field is optional and `null` clears the declaration back to the registry/catalog answer; display name is deliberately absent because `PUT /api/providers/{provider}/model-display-names` already owns it. | | Effort and fallback | `src/server/management/agent-settings-routes.ts` — `GET/PUT /api/effort-caps`, `/api/subagent-models`, `/api/subagent-model-fallback`. Caps clamp; they do not reject. | -| Grok and Claude integrations | `src/server/management/agent-settings-routes.ts` — `GET /api/grok`, `PUT /api/grok/selection`, `POST /api/grok/apply`, `GET/PUT /api/claude-desktop`, `POST /api/claude-desktop/apply` (`mode`: `gateway` default for new installs, `first-party` opt-in, or legacy shapes), `GET /api/claude-desktop/status` (`mode`, `riskWarning`, `firstParty`), `GET/PUT /api/claude-code`. Gateway apply writes an external app's profile, so its status probe must read the same resolved path it writes (see [`responses.md`](transports/responses.md)); first-party apply writes only the Claude Code proxy env, see [`clients/claude-desktop.md`](clients/claude-desktop.md#desktop-modes-gateway-and-first-party). `gui/src/pages/ClaudeDesktop.tsx` renders the mode selector and sends the chosen `mode` with apply. It leads with Default model and Quick task model, which set the `opus` and `haiku` family defaults through `gui/src/pages/claude-desktop-roles.ts` (same move rules as the family lanes, which sit under a folded Advanced disclosure); the profile wire shape is unchanged. `gui/src/pages/ClaudeCode.tsx` is one page with a single Save bar; its Save body (`gui/src/pages/claude-code-save.ts`) omits `enabled`, which only the immediate connection switch writes. `PUT /api/claude-code` re-runs macOS system-env reconciliation (`src/server/system-env.ts`) whenever the body carries `systemEnv`, `authMode`, a model slot or a lever field: keys opencodex tracks as injected are refreshed or unset once the config stops producing them, and a launchd value the user set before injection is never touched. `gui/src/pages/claude-code-tier-defaults.tsx` edits `tierModels` through the Save bar's dirty/poll/revert contract. `GET /api/claude-code/model-defaults` previews local user defaults; `POST` accepts exactly a fingerprint bound to the full settings document and saved tiers (`src/claude/model-default-settings.ts`). Changed settings/tiers return 409 and existing empty/corrupt documents are refused. Apply stages private bytes, captures the actual shared file into `model-default-backups/apply-*/settings.json` under the Claude config directory, validates that capture, then publishes with an exclusive hard link. A competing destination is never replaced, even on rollback; there is a brief missing-file interval, and unsupported cross-volume/link operations have no overwrite fallback. Failed restoration returns `settings_recovery_required` with the private recovery path shown as an alert. Selected aliases receive paired `_MODEL_NAME` labels via `claudeTierDisplayName` in `src/claude/context-windows.ts`. Preview includes label additions/updates and uses an empty `after` string for removal. An existing label is managed only when it exactly matches the generated name for its current alias (an identical manually entered label is indistinguishable); custom, empty and non-string labels are preserved. Unset tiers retain both their settings model IDs and display labels; a matching generated label is removed only when an explicitly selected tier is replaced with a non-alias; no client restart, connection credential change or Desktop family edit occurs. Covered by `tests/claude-integration/claude-model-default-labels.test.ts`, `tests/claude-integration/claude-intercept-settings.test.ts`, `tests/server/model-settings-management-api.test.ts`, `gui/tests/claude-code-save.test.ts` and `gui/tests/claude-code-background-helper.test.tsx`. | +| Grok and Claude integrations | `src/server/management/agent-settings-routes.ts` — `GET /api/grok`, `PUT /api/grok/selection`, `POST /api/grok/apply`, `GET/PUT /api/claude-desktop`, `POST /api/claude-desktop/apply` (`mode`: `gateway` default for new installs, `first-party` opt-in, or legacy shapes), `GET /api/claude-desktop/status` (`mode`, `riskWarning`, `firstParty`), `GET/PUT /api/claude-code`. Gateway apply writes an external app's profile, so its status probe must read the same resolved path it writes (see [`responses.md`](transports/responses.md)); first-party apply writes only the Claude Code proxy env, see [`clients/claude-desktop.md`](clients/claude-desktop.md#desktop-modes-gateway-and-first-party). `gui/src/pages/ClaudeDesktop.tsx` renders the mode selector and sends the chosen `mode` with apply. It leads with Default model and Quick task model, which set the `opus` and `haiku` family defaults through `gui/src/pages/claude-desktop-roles.ts` (same move rules as the family lanes, which sit under a folded Advanced disclosure); the profile wire shape is unchanged. `gui/src/pages/ClaudeCode.tsx` is one page with a single Save bar; its Save body (`gui/src/pages/claude-code-save.ts`) omits the top-level `enabled`, which only the immediate connection switch writes. `PUT /api/claude-code` re-runs macOS system-env reconciliation (`src/server/system-env.ts`) whenever the body carries `systemEnv`, `authMode`, a model slot or a lever field: keys opencodex tracks as injected are refreshed or unset once the config stops producing them, and a launchd value the user set before injection is never touched. `gui/src/pages/claude-code-tier-defaults.tsx` edits `tierModels` through the Save bar's dirty/poll/revert contract. `GET /api/claude-code/model-defaults` previews local user defaults; `POST` accepts exactly a fingerprint bound to the full settings document and saved tiers (`src/claude/model-default-settings.ts`). Changed settings/tiers return 409 and existing empty/corrupt documents are refused. Apply stages private bytes, captures the actual shared file into `model-default-backups/apply-*/settings.json` under the Claude config directory, validates that capture, then publishes with an exclusive hard link. A competing destination is never replaced, even on rollback; there is a brief missing-file interval, and unsupported cross-volume/link operations have no overwrite fallback. Failed restoration returns `settings_recovery_required` with the private recovery path shown as an alert. Selected aliases receive paired `_MODEL_NAME` labels via `claudeTierDisplayName` in `src/claude/context-windows.ts`. Preview includes label additions/updates and uses an empty `after` string for removal. An existing label is managed only when it exactly matches the generated name for its current alias (an identical manually entered label is indistinguishable); custom, empty and non-string labels are preserved. Unset tiers retain both their settings model IDs and display labels; a matching generated label is removed only when an explicitly selected tier is replaced with a non-alias; no client restart, connection credential change or Desktop family edit occurs. Covered by `tests/claude-integration/claude-model-default-labels.test.ts`, `tests/claude-integration/claude-intercept-settings.test.ts`, `tests/server/model-settings-management-api.test.ts`, `gui/tests/claude-code-save.test.ts` and `gui/tests/claude-code-background-helper.test.tsx`. Claude sidecar overrides have their own optional boolean `enabled` in GET and validated PUT. `gui/src/pages/claude-code-sidecar.ts` preserves an untouched off override even without a backend/model; Inherit explicitly clears it, while selecting a nonempty model can re-enable it through the existing target validation. Model edits preserve an already enabled override; typing then clearing a model in an off draft returns it to off. Draft provenance never enters PUT or confirmed saved state. | The model-settings route rejects unknown fields, non-exact or reserved model IDs, and context windows outside the positive safe integers. It computes per-model maps on copies and publishes diff --git a/tests/config/config-removed-opencode-free.test.ts b/tests/config/config-removed-opencode-free.test.ts index 9e7532db0ad..6588a8c4df3 100644 --- a/tests/config/config-removed-opencode-free.test.ts +++ b/tests/config/config-removed-opencode-free.test.ts @@ -3,6 +3,10 @@ import { readFileSync, writeFileSync } from "node:fs"; import { armClaudeCodeBaseline, getConfigPath, getDefaultConfig, loadConfig, saveConfigPreservingClaudeCode } from "../../src/config"; import { readConfigDiagnostics } from "../../src/config/diagnostics"; import { PROVIDER_REGISTRY } from "../../src/providers/registry"; +import { buildClaudeReplayConfig } from "../../src/server/claude-messages"; +import { parseRequest } from "../../src/responses/parser"; +import { planVisionSidecar, shouldResolveOpenAiVisionSidecar } from "../../src/vision/plan"; +import { planWebSearch, shouldResolveOpenAiWebSearchSidecar } from "../../src/web-search"; import { createTempHome } from "../helpers/temp-home"; test("retired OpenCode Free entries and dependent routes are skipped on load, once per process", () => { @@ -179,8 +183,8 @@ test("all routed reference sites and whitespace aliases are pruned while healthy const loaded = loadConfig(); expect(readConfigDiagnostics().source).toBe("file"); expect(loaded.codexRoleTiers).toEqual({ fast: ["healthy/keep"], standard: [], frontier: [] }); - expect(loaded.visionSidecar).toBeUndefined(); - expect(loaded.webSearchSidecar).toBeUndefined(); + expect(loaded.visionSidecar).toEqual({ backend: "routed", enabled: false }); + expect(loaded.webSearchSidecar).toEqual({ backend: "openai", enabled: false }); expect(loaded.shadowCallIntercept).toEqual({ enabled: true, model: "healthy/keep", sourceModels: ["healthy/*"] }); expect(loaded.providers.healthy.defaultModel).toBeUndefined(); expect(loaded.providers.healthy.autoReviewModel).toBeUndefined(); @@ -192,6 +196,110 @@ test("all routed reference sites and whitespace aliases are pruned while healthy }); }); +test.each([false, true, undefined])("retired sidecar targets stay disabled through load, diagnostics and saves (enabled: %s)", enabled => { + const routes = { + combos: { retired: { alias: " retired-combo ", targets: [{ provider: "opencode-free", model: "old" }] } }, + routingProfiles: { retired: { alias: " retired-policy ", candidates: [{ provider: "opencode-free", model: "old" }] } }, + }; + for (const model of ["opencode-free/old", " retired-alias/old ", "combo/retired", "retired-combo", "policy/retired", "retired-policy"]) { + const settings = { ...(enabled === undefined ? {} : { enabled }), model, timeoutMs: 12_345, reasoning: "low" }; + const vision = { ...settings, backend: "routed", maxDescriptionsPerTurn: 2 }; + const search = { ...settings, backend: "openai", maxSearchesPerTurn: 3 }; + withLegacyConfig({ ...routes, visionSidecar: vision, webSearchSidecar: search }, bytes => { + const expectedVision = { backend: "routed", enabled: false, timeoutMs: 12_345, reasoning: "low", maxDescriptionsPerTurn: 2 }; + const expectedSearch = { backend: "openai", enabled: false, timeoutMs: 12_345, reasoning: "low", maxSearchesPerTurn: 3 }; + const live = loadConfig(); + const check = (config: ReturnType) => { + expect(config.visionSidecar).toEqual(expectedVision); + expect(config.webSearchSidecar).toEqual(expectedSearch); + }; + check(live); + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.source).toBe("file"); + check(diagnostics.config); + expect(readFileSync(getConfigPath(), "utf8")).toBe(bytes); + armClaudeCodeBaseline(live); + for (const fastMode of [true, false]) { + live.fastMode = fastMode; + saveConfigPreservingClaudeCode(live); + check(live); + check(JSON.parse(readFileSync(getConfigPath(), "utf8"))); + check(loadConfig()); + expect(loadConfig().fastMode).toBe(fastMode); + } + }); + } +}, 60_000); + +test.each([false, true, undefined])("healthy sidecar settings keep their activation and targets (enabled: %s)", enabled => { + const settings = { ...(enabled === undefined ? {} : { enabled }), model: "healthy/keep", reasoning: "low", timeoutMs: 12_345 }; + const visionSidecar = { ...settings, backend: "routed", maxDescriptionsPerTurn: 2 }; + const webSearchSidecar = { ...settings, backend: "openai", maxSearchesPerTurn: 3 }; + withLegacyConfig({ visionSidecar, webSearchSidecar }, bytes => { + const live = loadConfig(); + expect(live.visionSidecar).toEqual(visionSidecar); + expect(live.webSearchSidecar).toEqual(webSearchSidecar); + expect(readConfigDiagnostics().config.visionSidecar).toEqual(visionSidecar); + expect(readConfigDiagnostics().config.webSearchSidecar).toEqual(webSearchSidecar); + expect(readFileSync(getConfigPath(), "utf8")).toBe(bytes); + armClaudeCodeBaseline(live); + live.fastMode = true; + saveConfigPreservingClaudeCode(live); + expect(loadConfig().visionSidecar).toEqual(visionSidecar); + expect(loadConfig().webSearchSidecar).toEqual(webSearchSidecar); + }); +}); + +test.each([false, true, undefined])("Claude retired overrides stay disabled without changing healthy globals (enabled: %s)", enabled => { + const request = parseRequest({ model: "healthy/blind", tools: [{ type: "web_search" }], input: [{ role: "user", content: [ + { type: "input_text", text: "Describe and search." }, { type: "input_image", image_url: "data:image/png;base64,aGVsbG8=" }, + ] }] }); + const globalVision = { enabled: true, backend: "routed", model: "healthy/vision", timeoutMs: 12_345 }; + const globalSearch = { enabled: true, backend: "exa", model: "healthy/search", exaApiKey: "fixture-exa-key" }; + for (const globals of [false, true]) for (const model of ["opencode-free/old", " retired-alias/old ", "combo/retired", "retired-combo", "policy/retired", "retired-policy"]) { + const activation = enabled === undefined ? {} : { enabled }; + withLegacyConfig({ + ...(globals ? { visionSidecar: globalVision, webSearchSidecar: globalSearch } : {}), + combos: { retired: { alias: " retired-combo ", targets: [{ provider: "opencode-free", model: "old" }] } }, + routingProfiles: { retired: { alias: " retired-policy ", candidates: [{ provider: "opencode-free", model: "old" }] } }, + claudeCode: { enabled: true, visionSidecar: { ...activation, backend: "routed", model }, webSearchSidecar: { ...activation, backend: "openai", model } }, + }, bytes => { + const live = loadConfig(); + live.providers.healthy!.modelInputModalities = { blind: ["text"], vision: ["text", "image"] }; + const check = (config: ReturnType) => { + expect(config.visionSidecar).toEqual(globals ? globalVision : undefined); + expect(config.webSearchSidecar).toEqual(globals ? globalSearch : undefined); + expect(config.claudeCode?.visionSidecar).toEqual({ enabled: false, backend: "routed" }); + expect(config.claudeCode?.webSearchSidecar).toEqual({ enabled: false, backend: "openai" }); + const effective = buildClaudeReplayConfig(config); + const provider = { ...config.providers.healthy!, noVisionModels: ["blind"] }; + expect(planVisionSidecar(effective, provider, "blind", request, undefined, { providerName: "healthy" })).toBeUndefined(); + expect(shouldResolveOpenAiVisionSidecar(effective, provider, "blind", request, "healthy")).toBe(false); + expect(planWebSearch(effective, request, false, provider, "blind")).toBeUndefined(); + expect(shouldResolveOpenAiWebSearchSidecar(effective, request, false)).toBe(false); + }; + check(live); + expect(readConfigDiagnostics().source).toBe("file"); + check(readConfigDiagnostics().config); + expect(readFileSync(getConfigPath(), "utf8")).toBe(bytes); + // The same legitimate global/default config remains usable outside the Claude override. + const provider = live.providers.healthy!; + expect(planVisionSidecar(live, provider, "blind", request, undefined, { providerName: "healthy" })?.backend).toBe(globals ? "routed" : undefined); + expect(planWebSearch(live, request, false, provider, "blind")?.backend).toBe(globals ? "exa" : undefined); + expect(shouldResolveOpenAiVisionSidecar(live, provider, "blind", request, "healthy")).toBe(!globals); + expect(shouldResolveOpenAiWebSearchSidecar(live, request, false)).toBe(!globals); + armClaudeCodeBaseline(live); + for (const fastMode of [true, false]) { + live.fastMode = fastMode; + saveConfigPreservingClaudeCode(live); + check(live); + check(JSON.parse(readFileSync(getConfigPath(), "utf8"))); + check(loadConfig()); + } + }); + } +}, 60_000); + test("unrelated providers and config remain unchanged", () => { const home = createTempHome("ocx-retained-provider-"); const warn = spyOn(console, "warn").mockImplementation(() => {}); diff --git a/tests/server/management-sidecar-anthropic-instance.test.ts b/tests/server/management-sidecar-anthropic-instance.test.ts index ace40532a37..25b5c4c54e1 100644 --- a/tests/server/management-sidecar-anthropic-instance.test.ts +++ b/tests/server/management-sidecar-anthropic-instance.test.ts @@ -1,9 +1,17 @@ import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; -import { readFileSync } from "node:fs"; +import { readFileSync, writeFileSync } from "node:fs"; import * as store from "../../src/oauth/store"; import * as modelRows from "../../src/server/management/model-rows"; import * as managementShared from "../../src/server/management/shared"; -import { saveConfig } from "../../src/config"; +import { armClaudeCodeBaseline, getConfigPath, loadConfig, saveConfig } from "../../src/config"; +import * as systemEnv from "../../src/server/system-env"; +import { buildClaudeReplayConfig } from "../../src/server/claude-messages"; +import { parseRequest } from "../../src/responses/parser"; +import { planVisionSidecar } from "../../src/vision"; +import { planWebSearch } from "../../src/web-search"; +import { claudeCodeSaveBody } from "../../gui/src/pages/claude-code-save"; +import { applySidecarBackendChange, applySidecarModelChange, sidecarSelectValue } from "../../gui/src/pages/claude-code-sidecar"; +import type { ClaudeCodeState } from "../../gui/src/pages/claude-code-types"; import { anthropicOAuthInstanceConfigError, anthropicSidecarPatchError } from "../../src/config/schema/anthropic-account-pool"; import { handleManagementAPI } from "../../src/server/management-api"; import { sidecarAnthropicPoolOptions, sidecarOptionsAuth } from "../../src/server/management/web-search-sidecar-options"; @@ -53,6 +61,127 @@ function persisted(): OcxConfig { return JSON.parse(readFileSync(home.path("config.json"), "utf8")) as OcxConfig; } +test.each([false, true])("normal Claude management saves retain retired disable intent; explicit reset and model edits work (backend: %s)", async withBackend => { + let outbound = 0; + const network = spyOn(globalThis, "fetch").mockImplementation(async () => { outbound++; throw new Error("Network forbidden in settings roundtrip"); }); + const env = spyOn(systemEnv, "applySystemEnvToggle").mockResolvedValue({ reverted: false }); + try { + const global = { enabled: true, backend: "openai" as const, model: "gpt-5.6-luna" }; + const provider: OcxConfig["providers"][string] = { adapter: "openai-chat", baseUrl: "https://routed.test/v1", apiKey: "fixture-key", noVisionModels: ["blind"] }; + const forward = { providerName: "openai" as const, accountMode: "direct" as const, + provider: { adapter: "openai-responses", baseUrl: "https://chatgpt.test/v1", authMode: "forward" } as const, + authContext: { kind: "main" as const, accountId: null }, headers: new Headers({ authorization: "Bearer fixture" }) }; + const retired = { model: "opencode-free/old", ...(withBackend ? { backend: "openai" as const } : {}) }; + writeFileSync(getConfigPath(), JSON.stringify({ port: 10100, defaultProvider: "routed", providers: { routed: provider }, + webSearchSidecar: global, visionSidecar: global, claudeCode: { enabled: false, webSearchSidecar: retired, visionSidecar: retired } })); + let live = loadConfig(); + armClaudeCodeBaseline(live); + const parsed = parseRequest({ model: "routed/blind", tools: [{ type: "web_search" }], input: [{ role: "user", content: [ + { type: "input_text", text: "Search and inspect." }, { type: "input_image", image_url: "data:image/png;base64,aGVsbG8=" }, + ] }] }); + const checkPlans = (config: OcxConfig, enabled: boolean) => { + const effective = buildClaudeReplayConfig(config); + expect(!!planVisionSidecar(effective, provider, "blind", parsed, forward)).toBe(enabled); + expect(!!planWebSearch(effective, parsed, false, provider, "blind", forward)).toBe(enabled); + expect(config.visionSidecar).toEqual(global); + expect(config.webSearchSidecar).toEqual(global); + }; + for (const fastMode of [true, false]) { + const state = await (await request(live, "/api/claude-code")).json() as ClaudeCodeState; + const body = claudeCodeSaveBody({ ...state, fastMode }, []); + expect((await request(live, "/api/claude-code", body)).status).toBe(200); + live = loadConfig(); + checkPlans(live, false); + expect(live.claudeCode?.visionSidecar?.enabled).toBe(false); + expect(live.claudeCode?.webSearchSidecar?.enabled).toBe(false); + expect(live.fastMode).toBe(fastMode); + } + const state = await (await request(live, "/api/claude-code")).json() as ClaudeCodeState; + const inherit = claudeCodeSaveBody({ ...state, + visionSidecar: applySidecarBackendChange(state.visionSidecar, "inherit"), + webSearchSidecar: applySidecarBackendChange(state.webSearchSidecar, "inherit"), + }, []); + expect((await request(live, "/api/claude-code", inherit)).status).toBe(200); + live = loadConfig(); + expect(live.claudeCode?.visionSidecar).toBeUndefined(); + expect(live.claudeCode?.webSearchSidecar).toBeUndefined(); + checkPlans(live, true); + expect((await request(live, "/api/claude-code", { visionSidecar: { enabled: false }, webSearchSidecar: { enabled: false } })).status).toBe(200); + const disabled = await (await request(live, "/api/claude-code")).json() as ClaudeCodeState; + const abandoned = claudeCodeSaveBody({ ...disabled, + visionSidecar: applySidecarModelChange(applySidecarModelChange(disabled.visionSidecar, "gpt-5.6-luna"), ""), + webSearchSidecar: applySidecarModelChange(applySidecarModelChange(disabled.webSearchSidecar, "gpt-5.6-luna"), ""), + }, []); + expect((await request(live, "/api/claude-code", abandoned)).status).toBe(200); + live = loadConfig(); + checkPlans(live, false); + const beforeInvalidEdit = readFileSync(getConfigPath(), "utf8"); + for (const field of ["visionSidecar", "webSearchSidecar"] as const) { + const invalid = claudeCodeSaveBody({ ...disabled, [field]: applySidecarModelChange(disabled[field], + field === "visionSidecar" ? "routed/blind" : "not-a-search-candidate") }, []); + expect((await request(live, "/api/claude-code", invalid)).status).toBe(400); + expect(readFileSync(getConfigPath(), "utf8")).toBe(beforeInvalidEdit); + expect(loadConfig().claudeCode?.[field]?.enabled).toBe(false); + } + const reenable = claudeCodeSaveBody({ ...disabled, + visionSidecar: applySidecarModelChange(disabled.visionSidecar, "gpt-5.6-luna"), + webSearchSidecar: applySidecarModelChange(disabled.webSearchSidecar, "gpt-5.6-luna"), + }, []); + expect((await request(live, "/api/claude-code", reenable)).status).toBe(200); + live = loadConfig(); + expect(live.claudeCode?.visionSidecar?.enabled).toBe(true); + expect(live.claudeCode?.webSearchSidecar?.enabled).toBe(true); + checkPlans(live, true); + expect(outbound).toBe(0); + } finally { env.mockRestore(); network.mockRestore(); } +}, 60_000); + +test("Claude sidecar enabled writes reject non-booleans without changing config", async () => { + const config = anthropicInstanceConfig(); + saveConfig(config); + const bytes = readFileSync(getConfigPath(), "utf8"); + for (const field of ["visionSidecar", "webSearchSidecar"]) for (const enabled of [null, 0, "false", {}, []]) { + expect((await request(config, "/api/claude-code", { [field]: { enabled } })).status).toBe(400); + expect(readFileSync(getConfigPath(), "utf8")).toBe(bytes); + expect(config.claudeCode?.[field as "visionSidecar"]).toBeUndefined(); + } +}); + +test.each(["backend", "model", "both"] as const)("Claude Auto edits clear persisted %s while retaining activation intent", async scenario => { + let outbound = 0; + const network = spyOn(globalThis, "fetch").mockImplementation(async () => { outbound++; throw new Error("Network forbidden in Auto roundtrip"); }); + const env = spyOn(systemEnv, "applySystemEnvToggle").mockResolvedValue({ reverted: false }); + try { + const global = { enabled: true, backend: "openai" as const, model: "gpt-5.6-luna" }; + for (const field of ["visionSidecar", "webSearchSidecar"] as const) { + const stored = { enabled: scenario !== "backend", + ...(scenario === "model" ? {} : { backend: field === "visionSidecar" ? "routed" : "anthropic" }), + ...(scenario === "backend" ? {} : { model: scenario === "both" ? (field === "visionSidecar" ? "routed/vision" : "claude-haiku-4-5") : "gpt-5.6-luna" }), + }; + writeFileSync(getConfigPath(), JSON.stringify({ port: 10100, defaultProvider: "routed", providers: { + routed: { adapter: "openai-chat", baseUrl: "https://routed.test/v1", apiKey: "fixture-key" }, + }, webSearchSidecar: global, visionSidecar: global, claudeCode: { enabled: false, [field]: stored } })); + const live = loadConfig(); + armClaudeCodeBaseline(live); + const state = await (await request(live, "/api/claude-code")).json() as ClaudeCodeState; + let draft = applySidecarBackendChange(state[field], "auto")!; + if (scenario !== "backend") draft = applySidecarModelChange(draft, ""); + const body = claudeCodeSaveBody({ ...state, [field]: draft, fastMode: true }, []); + expect((await request(live, "/api/claude-code", body)).status).toBe(200); + const reloaded = loadConfig(); + expect(reloaded.claudeCode?.[field]).toEqual({ enabled: stored.enabled }); + expect(Object.getOwnPropertySymbols(body[field]!)).toHaveLength(0); + expect(reloaded.visionSidecar).toEqual(global); + expect(reloaded.webSearchSidecar).toEqual(global); + const after = await (await request(reloaded, "/api/claude-code")).json() as ClaudeCodeState; + expect(sidecarSelectValue(after[field])).toBe("auto"); + expect(after[field]?.model).toBeUndefined(); + expect(reloaded.fastMode).toBe(true); + } + expect(outbound).toBe(0); + } finally { env.mockRestore(); network.mockRestore(); } +}, 60_000); + for (const instance of ["anthropic", "anthropic2"] as const) { test(`global web-search and vision round-trip ${instance}; omission preserves and null deletes`, async () => { const config = anthropicInstanceConfig(); diff --git a/tests/vision/mistral-vision-sidecar.test.ts b/tests/vision/mistral-vision-sidecar.test.ts index d6883474ad3..e2b0582fe18 100644 --- a/tests/vision/mistral-vision-sidecar.test.ts +++ b/tests/vision/mistral-vision-sidecar.test.ts @@ -1,32 +1,86 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtempSync } from "node:fs"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { saveConfig } from "../../src/config"; +import { getConfigPath, loadConfig, saveConfig } from "../../src/config"; import { buildCatalogEntries, gatherRoutedModels } from "../../src/codex/catalog"; import { clearModelCache } from "../../src/codex/model-cache"; +import { pendingBackgroundWork, settleBackgroundWork, trackBackgroundWork } from "../../src/lib/background-work"; +import { flushWindowsSecretAclReapsBeforeRemoval } from "../../src/lib/windows-secret-acl"; +import * as outbound from "../../src/lib/provider-outbound"; import { routeModel } from "../../src/router"; import { startServer } from "../../src/server"; import type { OcxConfig } from "../../src/types"; import { resetVisionDescriptionCache } from "../../src/vision"; import { requiresVisionPreprocessing } from "../../src/vision/plan"; import { fakeChatGptJwt } from "../helpers/fake-chatgpt-jwt"; -import { withStubbedProviderFetch } from "../helpers/catalog-provider-fetch"; +import { stubbedProviderFetch, withStubbedProviderFetch } from "../helpers/catalog-provider-fetch"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import * as removal from "../helpers/remove-tree"; const GLM = "zai-glm-5-3"; const NATIVE = "mistral-large-4-0"; const IMAGE = "data:image/png;base64,aGVsbG8taW1hZ2UtYnl0ZXM="; const CAPTION = "A red square with a white circle."; -const originalFetch = globalThis.fetch; +let forbiddenProviderCalls = 0; +const nativeFetch = globalThis.fetch; +const originalFetch = ((input, init) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (url.hostname === "127.0.0.1") return nativeFetch(input, init); + forbiddenProviderCalls++; + throw new Error("Network forbidden outside an active Mistral vision fixture"); +}) as typeof fetch; +const realOutboundGet = outbound.providerOutboundGet; +const realOutboundPost = outbound.providerOutboundPost; +let outboundGet: ReturnType>; +let outboundPost: ReturnType>; let previousHome: string | undefined; let home = ""; let codexHome: IsolatedCodexHome; let sidecarCalls: Array>; let mainCalls: Array>; +let proxy: ReturnType | undefined; +let stopServer: () => Promise | undefined; +let requestFlight: Promise<{ status: number; body: string }> | undefined; +let requestPending = false; +const ownedCleanup = new Set>(); +let cleanupFlight: Promise | undefined; +let sidecarWait: Promise | undefined; +let sidecarEntered: (() => void) | undefined; +let isolationActive = false; +let cleanupPending = false; +let cleanupRetryUsed = false; +let cleanupFailures: unknown[] = []; + +function assertFixtureReleased(): void { + if (isolationActive || requestPending || ownedCleanup.size) throw new Error("Previous fixture is undrained; refusing to replace its isolation"); +} + +beforeAll(() => { + globalThis.fetch = originalFetch; + // File reload drops function-valued provider.fetch. Keep discovery on its real + // transport path with an injected executor, including late startup work. + outboundGet = spyOn(outbound, "providerOutboundGet").mockImplementation((name, provider, url, init, deps) => + realOutboundGet(name, { ...provider, fetch: stubbedProviderFetch }, url, init, deps)); + outboundPost = spyOn(outbound, "providerOutboundPost").mockImplementation((name, provider, url, init, deps) => + realOutboundPost(name, { ...provider, fetch: stubbedProviderFetch }, url, init, deps)); +}); + +afterAll(async () => { + assertFixtureReleased(); + if (!(await settleBackgroundWork({ timeoutMs: 15_000 })).settled) throw new Error("Suite still owns background work; mocks retained"); + expect(forbiddenProviderCalls).toBe(0); + outboundGet.mockRestore(); + outboundPost.mockRestore(); + globalThis.fetch = nativeFetch; +}); beforeEach(() => { + assertFixtureReleased(); + isolationActive = true; + cleanupPending = cleanupRetryUsed = false; + cleanupFailures = []; previousHome = process.env.OPENCODEX_HOME; codexHome = installIsolatedCodexHome("ocx-mistral-vision-codex-"); home = mkdtempSync(join(tmpdir(), "ocx-mistral-vision-")); @@ -35,6 +89,12 @@ beforeEach(() => { resetVisionDescriptionCache(); sidecarCalls = []; mainCalls = []; + proxy = undefined; + stopServer = () => proxy?.stop(false); + requestFlight = cleanupFlight = undefined; + requestPending = false; + sidecarWait = undefined; + sidecarEntered = undefined; globalThis.fetch = (async (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input)); if (url.origin === "https://api.mistral.ai") { @@ -53,6 +113,8 @@ beforeEach(() => { } if (url.origin === "https://chatgpt.com" && url.pathname === "/backend-api/codex/responses") { sidecarCalls.push(JSON.parse(String(init?.body))); + sidecarEntered?.(); + await sidecarWait; return new Response(`data: ${JSON.stringify({ type: "response.output_text.delta", delta: CAPTION })}\n\ndata: [DONE]\n\n`, { headers: { "content-type": "text/event-stream" }, }); @@ -62,15 +124,99 @@ beforeEach(() => { }) as typeof fetch; }); -afterEach(() => { - globalThis.fetch = originalFetch; - clearModelCache(); - resetVisionDescriptionCache(); - if (previousHome === undefined) delete process.env.OPENCODEX_HOME; - else process.env.OPENCODEX_HOME = previousHome; - codexHome.restore(); - removeTreeWithRetry(home); -}); +function finishFixture(drainTimeoutMs = 15_000, cleanupTimeoutMs = 10_000): Promise { + return cleanupFlight ??= (async () => { + cleanupPending = true; + try { + // Background settlement alone does not own HTTP requests. Close ingress gracefully + // and await the complete response body before any fetch mock or home is restored. + try { await awaitOwnedCleanup(Promise.resolve().then(stopServer), "server shutdown", cleanupTimeoutMs); } + catch (error) { + cleanupFailures.push(error); + if (ownedCleanup.size) throw new AggregateError(cleanupFailures, "Fixture shutdown is pending; mocks retained"); + // A shutdown hook can fail after closing ingress. Retry gracefully: forcing + // a socket closed could reject the client while its handler still uses fetch. + try { await awaitOwnedCleanup(Promise.resolve().then(stopServer), "server shutdown retry", cleanupTimeoutMs); } + catch (retryError) { + cleanupFailures.push(retryError); + throw new AggregateError(cleanupFailures, "Fixture ingress did not confirm shutdown; mocks retained"); + } + } + try { + if (requestFlight) await awaitOwnedCleanup(requestFlight.catch(() => undefined), "response drain", cleanupTimeoutMs); + } catch (error) { + cleanupFailures.push(error); + throw new AggregateError(cleanupFailures, "Fixture response is pending; mocks retained"); + } + // A drain timeout is not a deletion failure: producers still own fetch and both homes. + try { + const drained = await settleBackgroundWork({ timeoutMs: drainTimeoutMs }); + if (!drained.settled) throw new Error(`Fixture background drain timed out: ${drained.pending.join(", ")}`); + await awaitOwnedCleanup(flushWindowsSecretAclReapsBeforeRemoval(home), "ACL reap", cleanupTimeoutMs); + if (pendingBackgroundWork().length) throw new Error("Fixture background work restarted during reap"); + } catch (error) { + cleanupFailures.push(error); + throw new AggregateError(cleanupFailures, "Fixture background work is undrained; mocks retained"); + } + // No asynchronous producer can start between the final check and guarded removal. + try { removeTreeWithRetry(home); } + catch (error) { cleanupFailures.push(error); } + finally { + clearModelCache(); + resetVisionDescriptionCache(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + try { codexHome.restore(); } + finally { globalThis.fetch = originalFetch; isolationActive = false; } + } + if (cleanupFailures.length) throw new AggregateError(cleanupFailures, "Fixture cleanup failed", { cause: cleanupFailures[0] }); + } finally { cleanupPending = false; } + })(); +} + +/** One explicit recovery after the owner releases retained work; preserve the initial failure. */ +function retryFixtureCleanup(): Promise { + if (!isolationActive || cleanupPending || cleanupRetryUsed || requestPending || ownedCleanup.size || pendingBackgroundWork().length) throw new Error("Fixture cleanup recovery is unavailable"); + cleanupRetryUsed = true; + cleanupFlight = undefined; + return finishFixture(); +} + +afterEach(() => finishFixture(), 60_000); + +function ownFixtureRequest(work: Promise<{ status: number; body: string }>) { + requestPending = true; + return requestFlight = work.finally(() => { requestPending = false; }); +} + +function imageRequest(model: string): Promise<{ status: number; body: string }> { + return ownFixtureRequest((async () => { + const token = fakeChatGptJwt({ chatgpt_account_id: "acct-mistral-vision-fixture" }); + const response = await fetch(new URL("/v1/responses", proxy!.url), { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${token}`, "chatgpt-account-id": "acct-mistral-vision-fixture" }, + body: JSON.stringify({ model, stream: false, input: [{ role: "user", content: [ + { type: "input_text", text: "Describe this image." }, { type: "input_image", image_url: IMAGE }, + ] }] }), + }); + return { status: response.status, body: await response.text() }; + })()); +} + +function awaitOwnedCleanup(work: Promise, label: string, timeoutMs = 10_000): Promise { + ownedCleanup.add(work); + void work.then(() => ownedCleanup.delete(work), () => ownedCleanup.delete(work)); + return fixtureDeadline(work, label, timeoutMs); +} + +async function fixtureDeadline(work: Promise, label: string, timeoutMs = 10_000): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([work, new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`Fixture timed out waiting for ${label}`)), timeoutMs); + })]); + } finally { clearTimeout(timer); } +} /** Build an isolated Mistral configuration with a caller-authenticated OpenAI vision helper. */ function configuration(): OcxConfig { @@ -87,6 +233,16 @@ function configuration(): OcxConfig { } describe("Mistral GLM vision sidecar defaults", () => { + test("file-reloaded provider discovery uses the fixture executor without native outbound transport", async () => { + saveConfig(configuration()); + const reloaded = loadConfig(); + expect(reloaded.providers.mistral?.fetch).toBeUndefined(); + const before = outboundGet.mock.calls.length; + const models = await gatherRoutedModels(reloaded); + expect(outboundGet.mock.calls.length).toBeGreaterThan(before); + expect(buildCatalogEntries(null, [], models).find(row => row.slug === `mistral/${GLM}`)?.input_modalities) + .toEqual(["text", "image"]); + }); test("live discovery publishes GLM attachments without a saved modality override", async () => { const config = configuration(); const models = await gatherRoutedModels(withStubbedProviderFetch(config)); @@ -132,43 +288,194 @@ describe("Mistral GLM vision sidecar defaults", () => { } }); - test.each(["sidecar", "disabled", "native"] as const)("image request uses the %s path", async mode => { + test.each(["sidecar", "disabled", "native", "retired-disabled", "retired-enabled", "retired-implicit"] as const)("image request uses the %s path", async mode => { const config = configuration(); if (mode === "disabled") config.visionSidecar = { enabled: false }; saveConfig(config); - const server = startServer(0); + if (mode.startsWith("retired-")) { + // Exercise file loading before real request admission, with a healthy helper available. + writeFileSync(getConfigPath(), JSON.stringify({ ...config, visionSidecar: { + backend: "routed", model: "opencode-free/old", + ...(mode === "retired-implicit" ? {} : { enabled: mode === "retired-enabled" }), + } })); + } + proxy = startServer(0); + const response = await imageRequest(`mistral/${mode === "native" ? NATIVE : GLM}`); + expect(response.status).toBe(200); + expect(mainCalls).toHaveLength(1); + expect(sidecarCalls).toHaveLength(mode === "sidecar" ? 1 : 0); + const forwarded = JSON.stringify(mainCalls[0]); + if (mode === "sidecar") { + expect(sidecarCalls[0]!.model).toBe("gpt-5.6-luna"); + expect(JSON.stringify(sidecarCalls[0])).toContain(IMAGE); + expect(forwarded).toContain(CAPTION); + } + if (mode === "native") expect(forwarded).toContain(IMAGE); + else { + expect(forwarded).not.toContain(IMAGE); + expect(forwarded).not.toContain("image_url"); + } + if (mode === "disabled" || mode.startsWith("retired-")) expect(forwarded).toContain("[image omitted:"); + }, 60_000); + + test("fixture retains its fetch mock until an in-flight request and server have finished", async () => { + let release!: () => void; + sidecarWait = new Promise(resolve => { release = resolve; }); + const entered = new Promise(resolve => { sidecarEntered = resolve; }); + saveConfig(configuration()); + proxy = startServer(0); + const guardedFetch = globalThis.fetch; + let requestFinished = false; + const response = imageRequest(`mistral/${GLM}`).then(value => { requestFinished = true; return value; }); try { - const token = fakeChatGptJwt({ chatgpt_account_id: "acct-mistral-vision-fixture" }); - const response = await fetch(new URL("/v1/responses", server.url), { - method: "POST", - headers: { - "content-type": "application/json", authorization: `Bearer ${token}`, - "chatgpt-account-id": "acct-mistral-vision-fixture", - }, - body: JSON.stringify({ model: `mistral/${mode === "native" ? NATIVE : GLM}`, stream: false, - input: [{ role: "user", content: [ - { type: "input_text", text: "Describe this image." }, { type: "input_image", image_url: IMAGE }, - ] }], - }), - }); - expect(response.status).toBe(200); - await response.text(); - expect(mainCalls).toHaveLength(1); - expect(sidecarCalls).toHaveLength(mode === "sidecar" ? 1 : 0); - const forwarded = JSON.stringify(mainCalls[0]); - if (mode === "sidecar") { - expect(sidecarCalls[0]!.model).toBe("gpt-5.6-luna"); - expect(JSON.stringify(sidecarCalls[0])).toContain(IMAGE); - expect(forwarded).toContain(CAPTION); + await fixtureDeadline(entered, "helper admission"); + expect((await settleBackgroundWork({ timeoutMs: 5_000 })).settled).toBe(true); + expect(requestFinished).toBe(false); + let cleanupFinished = false; + const cleanup = finishFixture().then(() => { cleanupFinished = true; }); + await Promise.resolve(); + expect(cleanupFinished).toBe(false); + expect(globalThis.fetch).toBe(guardedFetch); + release(); + await cleanup; + expect((await response).status).toBe(200); + expect(requestFinished).toBe(true); + expect(globalThis.fetch).toBe(originalFetch); + expect(sidecarCalls).toHaveLength(1); + } finally { release(); } + }, 60_000); + + test("fixture restores globals after a drained home removal failure", async () => { + const remove = spyOn(removal, "removeTreeWithRetry").mockImplementationOnce(() => { throw new Error("fixture removal failed"); }); + const attempt = finishFixture(); + cleanupFlight = attempt.catch(() => undefined); + try { + await expect(attempt).rejects.toThrow("Fixture cleanup failed"); + expect(globalThis.fetch).toBe(originalFetch); + expect(process.env.OPENCODEX_HOME).toBe(previousHome); + expect(process.env.CODEX_HOME).not.toBe(codexHome.path); + } finally { + remove.mockRestore(); + removeTreeWithRetry(home); + } + }, 60_000); + + test.each([1, 2])("fixture retains isolation through %s injected shutdown failures", async failures => { + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const response = ownFixtureRequest(gate.then(() => ({ status: 200, body: "fixture response drained" }))); + let calls = 0; + stopServer = async () => { + if (++calls <= failures) throw new Error("fixture shutdown failed"); + }; + const guardedFetch = globalThis.fetch; + try { + const attempt = finishFixture(); + cleanupFlight = attempt.catch(() => undefined); + let outcome: unknown; + if (failures === 2) { + // Ingress did not close: reject promptly without waiting for the held request. + outcome = await fixtureDeadline(attempt.catch(error => error), "shutdown refusal"); + expect(outcome).toBeInstanceOf(AggregateError); + expect(requestPending).toBe(true); + expect(() => retryFixtureCleanup()).toThrow("recovery is unavailable"); + } else { + for (let tick = 0; tick < 8; tick++) await Promise.resolve(); + expect(cleanupPending).toBe(true); } - if (mode === "native") expect(forwarded).toContain(IMAGE); - else { - expect(forwarded).not.toContain(IMAGE); - expect(forwarded).not.toContain("image_url"); + expect(globalThis.fetch).toBe(guardedFetch); + expect(process.env.OPENCODEX_HOME).toBe(home); + release(); + await response; + outcome ??= await fixtureDeadline(attempt.catch(error => error), "cleanup result"); + expect((outcome as AggregateError).errors[0].message).toBe("fixture shutdown failed"); + expect(globalThis.fetch).toBe(failures === 1 ? originalFetch : guardedFetch); + expect(calls).toBe(2); + } finally { + release(); + await response; + stopServer = () => undefined; + if (failures === 2 && isolationActive && !cleanupPending) { + const recovery = retryFixtureCleanup(); + cleanupFlight = recovery.catch(() => undefined); + await expect(recovery).rejects.toThrow("Fixture cleanup failed"); + expect(globalThis.fetch).toBe(originalFetch); } - if (mode === "disabled") expect(forwarded).toContain("[image omitted:"); + } + }, 60_000); + + test("fixture retains isolation after a real background drain timeout until its one recovery", async () => { + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const guardedFetch = globalThis.fetch; + const observed: { fetch?: typeof fetch; home?: string; codex?: string } = {}; + const work = trackBackgroundWork("held-fixture-observer", gate.then(() => { + observed.fetch = globalThis.fetch; + observed.home = process.env.OPENCODEX_HOME; + observed.codex = process.env.CODEX_HOME; + })); + try { + const attempt = finishFixture(1); + cleanupFlight = attempt.catch(() => undefined); + await expect(attempt).rejects.toThrow("undrained; mocks retained"); + expect(() => assertFixtureReleased()).toThrow("refusing to replace its isolation"); + expect(() => retryFixtureCleanup()).toThrow("recovery is unavailable"); + expect(finishFixture()).toBe(cleanupFlight); + expect(globalThis.fetch).toBe(guardedFetch); + release(); + await work; + expect(observed).toEqual({ fetch: guardedFetch, home, codex: codexHome.path }); + const recovery = retryFixtureCleanup(); + cleanupFlight = recovery.catch(() => undefined); + const failure = await recovery.catch(error => error as AggregateError); + expect(failure).toBeInstanceOf(AggregateError); + expect((failure as AggregateError).errors[0].message).toContain("held-fixture-observer"); + expect(globalThis.fetch).toBe(originalFetch); + expect(process.env.OPENCODEX_HOME).toBe(previousHome); + expect(process.env.CODEX_HOME).not.toBe(codexHome.path); + expect(() => assertFixtureReleased()).not.toThrow(); + expect(() => retryFixtureCleanup()).toThrow("recovery is unavailable"); } finally { - await server.stop(true); + release(); + await work; + if (isolationActive && !cleanupRetryUsed) { + cleanupFlight = retryFixtureCleanup().catch(() => undefined); + await cleanupFlight; + } } - }); + }, 60_000); + + test.each(["stop", "request"] as const)("fixture rejects a %s deadline without restoring or retrying live work", async phase => { + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const guardedFetch = globalThis.fetch; + if (phase === "stop") stopServer = () => gate; + else ownFixtureRequest(gate.then(() => ({ status: 200, body: "released" }))); + try { + const attempt = finishFixture(15_000, 1); + cleanupFlight = attempt.catch(() => undefined); + await expect(attempt).rejects.toThrow("mocks retained"); + expect(globalThis.fetch).toBe(guardedFetch); + expect(process.env.OPENCODEX_HOME).toBe(home); + expect(() => retryFixtureCleanup()).toThrow("recovery is unavailable"); + release(); + await (requestFlight ?? gate); + await fixtureDeadline(Promise.all([...ownedCleanup]), "released cleanup work"); + stopServer = () => undefined; + const recovery = retryFixtureCleanup(); + cleanupFlight = recovery.catch(() => undefined); + const failure = await recovery.catch(error => error as AggregateError); + expect((failure as AggregateError).errors[0].message).toContain("Fixture timed out waiting for"); + expect(globalThis.fetch).toBe(originalFetch); + } finally { + release(); + await (requestFlight ?? gate); + await fixtureDeadline(Promise.all([...ownedCleanup]), "released cleanup work"); + stopServer = () => undefined; + if (isolationActive && !cleanupRetryUsed && !cleanupPending) { + cleanupFlight = retryFixtureCleanup().catch(() => undefined); + await cleanupFlight; + } + } + }, 60_000); }); From 14fa422e02245c2b4e518c98a2505b800366a03d Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Mon, 26 Oct 2026 04:45:36 +0000 Subject: [PATCH 065/101] fix(meta): preserve destinations when refreshing saved model rosters (#7735) * fix(meta): migrate stale Model API rosters * docs(meta): keep roster migration contract within structure budget Join existing paragraph lines without changing words or policy. The provider structure document remains within its 600-line budget. --- .../src/content/docs/guides/providers.md | 4 ++ src/providers/stale-model-roster-migration.ts | 13 ++++ structure/providers-and-adapters.md | 8 +-- .../model-roster-seed-repair.test.ts | 71 ++++++++++++++++++- 4 files changed, 91 insertions(+), 5 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index f0ca98897e9..9d434d103a9 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -1118,6 +1118,10 @@ ladder, and `meta-model/muse-wonder-1.0` (Muse Wonder, preview) with `minimal` t Muse Wonder is Standard mode only on the Model API: it has no Contributor tier and costs $4 per 1M input and $20 per 1M output tokens. Muse Wonder Full is available only inside Muse Code subscriptions, so it is not seeded. +On startup, an untouched saved two-Spark model list gains Muse Wonder when `meta-model` uses +`https://api.meta.ai/v1` or has no saved endpoint override. Custom gateway destinations and +hand-edited model lists stay unchanged. Existing per-model context-window overrides and output +limits are preserved; Wonder's context window is filled only into an existing window map. Discovery stays off until an authenticated roster is verified, because Meta serves image and voice models on the same host. diff --git a/src/providers/stale-model-roster-migration.ts b/src/providers/stale-model-roster-migration.ts index b9383b6c658..6b627cd5840 100644 --- a/src/providers/stale-model-roster-migration.ts +++ b/src/providers/stale-model-roster-migration.ts @@ -26,6 +26,10 @@ import { KIMI_API_MODEL_CONTEXT_WINDOWS, KIMI_API_MODELS, KIMI_API_MODELS_BEFORE_K31, + META_MODEL_API_MODELS, + META_MUSE_CONTEXT_WINDOW, + META_MUSE_MODELS, + META_MUSE_WONDER_MODEL, MINIMAX_M31_DEFAULT_REASONING_EFFORT, MINIMAX_M31_FLASH_PREVIEW, MINIMAX_MODELS, @@ -79,6 +83,15 @@ const ALIBABA_INTL_TOKEN_PLAN_MODELS_BEFORE_1017 = [ * preview, so live discovery cannot add it; the catalog keeps it only when it is configured. */ export const STALE_MODEL_ROSTERS: readonly StaleModelRoster[] = [ + { + provider: "meta-model", + from: META_MUSE_MODELS, + to: META_MODEL_API_MODELS, + contextWindows: { [META_MUSE_WONDER_MODEL]: META_MUSE_CONTEXT_WINDOW }, + // A Custom row may keep this id pointed at another gateway + // (preserveCustomDestination); only api.meta.ai publishes the roster `to` describes. + destinationBaseUrls: ["https://api.meta.ai/v1"], + }, ...["minimax", "minimax-cn"].map(provider => ({ provider, from: MINIMAX_MODELS_BEFORE_M31, diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 8dae56c743c..32a4a3789c1 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -350,10 +350,10 @@ Provider-scoped capability hints remain authoritative when discovery returns an capabilities. In particular, `src/providers/registry/entries-core.ts` assigns OpenCode Go's live `deepseek-v4.1-flash` route the official 1,048,576-token window instead of the conservative 128k routed-model fallback. Custom-named Azure OpenAI providers obtain missing image/text input metadata and context/output limits through the destination-gated lookup in `src/providers/derive.ts`, consumed by shared catalog hints. Discovery refreshes public models.dev Azure rows through `src/providers/azure-model-metadata.ts`; stale cache and bundled hints remain available offline. This uses known model ids without changing transport selection or persisting model declarations; see [the shared catalog contract](catalog.md#shared-catalog). -Meta's two direct surfaces keep separate reasoning contracts: `meta-model` caps Muse Spark at `xhigh` -and gives Muse Wonder (`muse-wonder-1.0`) its documented `minimal`..`max` ladder, while `meta-muse` -advertises `max` and sends the transparent Muse compatibility User-Agent required by that -credential surface. The existing registry header merge keeps an operator-supplied User-Agent authoritative. +Meta's two direct surfaces keep separate reasoning contracts: `meta-model` caps Muse Spark at `xhigh` and gives Muse Wonder (`muse-wonder-1.0`) its documented `minimal`..`max` ladder, while `meta-muse` +advertises `max` and sends the transparent Muse compatibility User-Agent required by that credential surface. The existing registry header merge keeps an operator-supplied User-Agent authoritative. +At startup, `src/providers/stale-model-roster-migration.ts` refreshes the untouched two-Spark `meta-model` seed to include Muse Wonder only on `https://api.meta.ai/v1` or the registry default. +Custom destinations and edited rosters stay unchanged; an existing context-window map gains only the missing Wonder entry, preserving saved values and output limits. The same registry declares the first-party `deepseek-flash` model with `text` and `image` input, so it bypasses the vision sidecar by default; explicit `noVisionModels` or text-only declarations remain authoritative. First-party `deepseek-chat`, `deepseek-reasoner`, and `deepseek-v4-flash` diff --git a/tests/providers/model-roster-seed-repair.test.ts b/tests/providers/model-roster-seed-repair.test.ts index 8ad13ce55a9..0c7c3d390d4 100644 --- a/tests/providers/model-roster-seed-repair.test.ts +++ b/tests/providers/model-roster-seed-repair.test.ts @@ -3,7 +3,15 @@ import { mergeConfiguredModelsIntoLiveCatalog } from "../../src/codex/catalog/pr import { routedMaxOutputTokens } from "../../src/codex/catalog/model-hints"; import { projectStartupConfigRepairs } from "../../src/providers/model-rename-startup"; import { projectStaleModelRosters, STALE_MODEL_ROSTERS } from "../../src/providers/stale-model-roster-migration"; -import { ALIBABA_TOKEN_PLAN_MAX_OUTPUT_TOKENS, MINIMAX_MODELS, MINIMAX_MODELS_BEFORE_M31 } from "../../src/providers/registry/model-seeds"; +import { + ALIBABA_TOKEN_PLAN_MAX_OUTPUT_TOKENS, + META_MODEL_API_MODELS, + META_MUSE_CONTEXT_WINDOW, + META_MUSE_MODELS, + META_MUSE_WONDER_MODEL, + MINIMAX_MODELS, + MINIMAX_MODELS_BEFORE_M31, +} from "../../src/providers/registry/model-seeds"; import { PROVIDER_REGISTRY } from "../../src/providers/registry"; import type { OcxConfig, OcxProviderConfig } from "../../src/types"; @@ -18,6 +26,35 @@ function minimaxConfig(provider: string, row: Partial): OcxCo } describe("stale model roster migration", () => { + test("adds Muse Wonder to an untouched saved Meta Model API seed", () => { + const config = { providers: { "meta-model": { + adapter: "openai-responses", + models: [...META_MUSE_MODELS], + modelContextWindows: { "muse-spark-1.3": META_MUSE_CONTEXT_WINDOW }, + } } } as unknown as OcxConfig; + + const projection = projectStartupConfigRepairs(config); + const row = projection.config.providers!["meta-model"]!; + expect(projection.changed).toBe(true); + expect(row.models).toEqual(META_MODEL_API_MODELS); + expect(row.modelContextWindows).toEqual({ + "muse-spark-1.3": META_MUSE_CONTEXT_WINDOW, + [META_MUSE_WONDER_MODEL]: META_MUSE_CONTEXT_WINDOW, + }); + expect(projection.warnings.join(" ")).toContain(`added ${META_MUSE_WONDER_MODEL}`); + expect(projectStartupConfigRepairs(structuredClone(projection.config)).changed).toBe(false); + }); + + test("preserves a hand-edited Meta Model API roster", () => { + const models = [...META_MUSE_MODELS, "custom-model"]; + const config = { providers: { "meta-model": { + adapter: "openai-responses", models: [...models], + } } } as unknown as OcxConfig; + + expect(projectStaleModelRosters(config).changed).toBe(false); + expect(config.providers!["meta-model"]!.models).toEqual(models); + }); + test("adds the preview to an untouched saved MiniMax seed on both presets", () => { // A config saved since 2026-07-10 carries exactly this eight-id copy, and enrichment // never rewrites a present list. MiniMax's /v1/models omits the preview, so without @@ -239,6 +276,38 @@ describe("International roster refresh follows the saved destination", () => { } }); }); +describe("Meta Model API roster refresh follows the saved destination", () => { + const entry = STALE_MODEL_ROSTERS.find(row => row.provider === "meta-model")!; + const row = (baseUrl: string | undefined) => ({ providers: { [entry.provider]: { + adapter: "openai-responses", ...(baseUrl === undefined ? {} : { baseUrl }), models: [...entry.from], + modelContextWindows: { "muse-spark-1.3": 262_144 }, + modelMaxOutputTokens: { "muse-spark-1.3": 8_192 }, + } } } as unknown as OcxConfig); + + test("leaves a Custom destination's roster and saved metadata untouched", () => { + // A preserved custom provider can still carry the two Spark ids; the refresh must not + // add Wonder to a gateway that is not api.meta.ai. + const config = row("https://gateway.example.test/v1"); + const original = structuredClone(config); + expect(projectStaleModelRosters(config).changed).toBe(false); + expect(config).toEqual(original); + }); + + test("refreshes the Model API host in any spelling and a row using the registry default", () => { + for (const baseUrl of [undefined, "HTTPS://api.meta.ai/v1/"]) { + const config = row(baseUrl); + expect(projectStaleModelRosters(config).changed).toBe(true); + const saved = config.providers![entry.provider]!; + expect(saved.models).toEqual([...entry.to]); + expect(saved.baseUrl).toBe(baseUrl); + expect(saved.modelContextWindows).toEqual({ + "muse-spark-1.3": 262_144, [META_MUSE_WONDER_MODEL]: META_MUSE_CONTEXT_WINDOW, + }); + expect(saved.modelMaxOutputTokens).toEqual({ "muse-spark-1.3": 8_192 }); + } + }); +}); + describe("Moonshot K3.1 Pro saved roster refresh", () => { // The roster every install saved between the Kimi K3 seed (260717) and #7653. Frozen here on // purpose: a later K2 retirement must not silently change what this refresh matches. From 0eb8cd0eaa540500c58cd7fadd5096d5f82a84db Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Mon, 26 Oct 2026 04:46:44 +0000 Subject: [PATCH 066/101] fix(cursor): use Bun glob matching for native grep (#7630) * fix(test): promote Bun 1.4.4 with independent runner role verification * docs: align Docker Bun comments with unified role defaults * fix(cursor): use Bun glob matching for native grep Replace suffix and substring approximations with one Bun.Glob matcher per request. Apply basename or root-relative matching while retaining native execution opt-in, traversal exclusions, and existing search limits. Cover recursive patterns, literal names, Bun 1.4.4 extglobs and brace ranges, and dispatcher behavior; document the experimental native grep semantics. Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> * test: use a draining TLS origin for early upload replies * test: confine draining origin to buffered HTTP/1 upload * test: observe body before buffered early upload reply * test: close direct upload before fixture teardown --------- Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> Co-authored-by: JUN --- .../ko/reference/configuration/providers.md | 6 + .../docs/reference/configuration/providers.md | 6 + scripts/test-layout/layout-adapters.json | 1 + src/adapters/cursor/native-exec-fs.ts | 20 +-- structure/providers/cursor.md | 12 ++ tests/fixtures/test-layout-expected.json | 1 + .../cursor/cursor-native-grep-globs.test.ts | 150 ++++++++++++++++++ 7 files changed, 187 insertions(+), 9 deletions(-) create mode 100644 tests/providers/cursor/cursor-native-grep-globs.test.ts 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 f9b100294ec..0fc58dc0558 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -396,6 +396,12 @@ Cursor 서버 주도 로컬 도구는 기본값으로 비활성화됩니다. Cod 필드는 최상위가 아니라 `providers.cursor`에 설정하십시오. 대시보드에서는 **Providers → Cursor → Edit JSON**을 사용해 저장한 뒤 다시 시작합니다. 레거시 `unsafeAllowNativeLocalExec: true`는 `nativeLocalExec`이 설정되지 않았을 때만 `nativeLocalExec: "on"`과 같습니다. MCP, 화면 녹화, computer use는 `mcpServers`와 `desktopExecutor`가 따로 제어합니다. +네이티브 `grep`이 허용된 디렉터리 검색에서는 `glob`으로 파일을 고릅니다. `*.ts`는 모든 하위 폴더의 +파일명에 적용되고, `src/**/*.ts`는 선택한 검색 폴더를 기준으로 한 상대 경로에 적용됩니다(`./`로 시작해도 됩니다). 모든 운영체제에서 +패턴의 경로 구분자는 `/`를 사용합니다. 문자 범위와 중괄호 선택 패턴을 지원하며, Bun 1.4.4에서는 +`**/!(*.test).ts` 같은 제외 패턴과 `part-{1..3}.ts` 같은 숫자 범위도 지원합니다. +필터는 검색 폴더를 바꾸거나 네이티브 실행을 활성화하지 않습니다. + 각 `mcpServers.`는 `command`(stdio) 또는 `url`(Streamable HTTP) 중 하나를 받습니다. stdio는 `args`, `env`, `cwd`도 받습니다. HTTP는 `headers`를 받습니다. 둘 다 `enabled`(기본값 true)와 `toolPrefix`를 지원합니다. `desktopExecutor`는 `computerUseCommand`, `recordScreenCommand`, `cwd`, `env`, `timeoutMs`(기본값 `30000`)를 받습니다. 명령은 `sh -c`를 거치며 stdin에서 JSON 요청 하나를 읽고, stdout에 JSON 결과 하나를 써야 합니다. :::caution[보안] diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 9be5f65a5bb..d4d626e0c7a 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -1455,6 +1455,12 @@ Set `nativeLocalExec` on `providers.cursor`, not at the top level. In the dashbo `nativeLocalExec: "on"` only when `nativeLocalExec` is unset. MCP, screen recording, and computer use are controlled separately by `mcpServers` and `desktopExecutor`. +For an enabled native `grep` directory search, `glob` filters file names: `*.ts` matches at any +depth, while `src/**/*.ts` matches paths relative to the selected search directory (`./` is optional). Use `/` for +pattern separators on every platform. Character classes and brace alternatives are supported; +Bun 1.4.4 also supports extglobs such as `**/!(*.test).ts` and ranges such as `part-{1..3}.ts`. +These filters do not change the selected directory or enable native execution. + Foreground `shellArgs` and `shellStreamArgs` requests are rejected before process creation on Windows, macOS, and Linux, including with `nativeLocalExec: "on"`. They require a kernel-backed descendant owner that this implementation does not provide; use the client's shell tool instead. diff --git a/scripts/test-layout/layout-adapters.json b/scripts/test-layout/layout-adapters.json index 78ef6f6d22b..e1e5a5b00ba 100644 --- a/scripts/test-layout/layout-adapters.json +++ b/scripts/test-layout/layout-adapters.json @@ -1,4 +1,5 @@ { + "cursor-native-grep-globs.test.ts": "providers/cursor", "tool-identity-hash.test.ts": "adapters", "eventstream-fragmentation.test.ts": "responses", "google-vertex-key-project.test.ts": "adapters/google", diff --git a/src/adapters/cursor/native-exec-fs.ts b/src/adapters/cursor/native-exec-fs.ts index b9bc94a7434..4510ffb3aba 100644 --- a/src/adapters/cursor/native-exec-fs.ts +++ b/src/adapters/cursor/native-exec-fs.ts @@ -1,5 +1,5 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; -import { basename, dirname, extname, join, relative, resolve } from "node:path"; +import { basename, dirname, extname, join, relative, resolve, sep } from "node:path"; import { create } from "@bufbuild/protobuf"; import { DeleteErrorSchema, @@ -235,19 +235,21 @@ export function lsExec(execMsg: ExecServerMessage): Uint8Array { } } -function matchesGlob(file: string, glob?: string): boolean { - if (!glob) return true; - if (glob.startsWith("*.") || glob.startsWith("**/*.")) return file.endsWith(glob.slice(glob.indexOf("*") + 1)); - return file.includes(glob.replaceAll("*", "")); +function grepFileMatcher(root: string, glob?: string): (file: string) => boolean { + if (!glob) return () => true; + const matcher = new Bun.Glob(glob.replace(/^(?:\.\/)+/, "")); + // Bare patterns apply to each basename; slash patterns are relative to the selected root. + const pathPattern = glob.includes("/"); + return file => matcher.match(pathPattern ? relative(root, file).split(sep).join("/") : basename(file)); } -function collectFiles(root: string, glob?: string, out: string[] = []): string[] { +function collectFiles(root: string, matchesFile: (file: string) => boolean, out: string[] = []): string[] { if (out.length >= MAX_GREP_FILES) return out; for (const entry of readdirSync(root, { withFileTypes: true })) { if (entry.name === "node_modules" || entry.name === ".git") continue; const path = join(root, entry.name); - if (entry.isDirectory()) collectFiles(path, glob, out); - if (entry.isFile() && matchesGlob(path, glob)) out.push(path); + if (entry.isDirectory()) collectFiles(path, matchesFile, out); + if (entry.isFile() && matchesFile(path)) out.push(path); if (out.length >= MAX_GREP_FILES) break; } return out; @@ -269,7 +271,7 @@ export function grepExec(execMsg: ExecServerMessage): Uint8Array { const root = resolve(args.path || process.cwd()); try { const pattern = new RegExp(args.pattern, args.caseInsensitive ? "i" : ""); - const files = existsSync(root) && statSync(root).isDirectory() ? collectFiles(root, args.glob) : [root]; + const files = existsSync(root) && statSync(root).isDirectory() ? collectFiles(root, grepFileMatcher(root, args.glob)) : [root]; const matches = []; for (const file of files) { if (matches.length >= MAX_GREP_RESULTS) break; diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index 76e5b5dce88..eeb5576f462 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -20,6 +20,18 @@ approval and sandbox path. `nativeLocalExec: "on"` is the explicit config-owner local experiments; `off` and the backwards-compatible `codex-sandbox` spelling both fail closed. MCP, screen recording, and computer-use stay on their separate explicit executor/MCP config paths. +For native `grep` directory searches, `src/adapters/cursor/native-exec-fs.ts` compiles the optional +filter once with `Bun.Glob`. A pattern without `/` matches each basename at every depth; a pattern +with `/` matches the path relative to the selected search root, using `/` on every platform; +an optional leading `./` keeps that root-relative scope. +This supports `**/*.ts`, brace alternatives and character classes; Bun 1.4.4 also supplies extglobs +and brace ranges. Empty filters keep the unfiltered search. The existing directory walk skips +`node_modules`, `.git` and symlink entries, collects at most 500 matching files, reads only files +at most 1,000,000 bytes, and returns at most 200 files with text matches. Glob matching never +expands or changes the search root. Explicit single-file searches retain their existing behavior. +`tests/providers/cursor/cursor-native-grep-globs.test.ts` exercises the dispatch gate, file selection, +all three output modes, traversal exclusions and runtime-specific syntax. + Foreground `shellArgs` and `shellStreamArgs` are unavailable on every platform, including with `nativeLocalExec: "on"`. `src/adapters/cursor/native-foreground-shell.ts` rejects them before spawn until a kernel-backed descendant owner exists; process-group disappearance is not cleanup proof. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d41fccb67a1..c05bb1d4388 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -656,6 +656,7 @@ "cursor-message-mapper.test.ts": "providers/cursor", "cursor-native-exec-common.test.ts": "providers/cursor", "cursor-native-exec-policy.test.ts": "providers/cursor", "cursor-native-exec-shell.test.ts": "providers/cursor", "cursor-foreground-shell.test.ts": "providers/cursor", "cursor-native-exec.test.ts": "providers/cursor", + "cursor-native-grep-globs.test.ts": "providers/cursor", "cursor-oauth-shell.test.ts": "providers/cursor", "cursor-oauth.test.ts": "providers/cursor", "cursor-pool.test.ts": "providers/cursor", "cursor-protobuf-events.test.ts": "providers/cursor", "cursor-repetition-breaker.test.ts": "providers/cursor", "cursor-request-builder.test.ts": "providers/cursor", diff --git a/tests/providers/cursor/cursor-native-grep-globs.test.ts b/tests/providers/cursor/cursor-native-grep-globs.test.ts new file mode 100644 index 00000000000..46430eda2ba --- /dev/null +++ b/tests/providers/cursor/cursor-native-grep-globs.test.ts @@ -0,0 +1,150 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, relative, sep } from "node:path"; +import { create, fromBinary } from "@bufbuild/protobuf"; +import { handleCursorNativeExec } from "../../../src/adapters/cursor/native-exec"; +import { + AgentClientMessageSchema, + ExecServerMessageSchema, + GrepArgsSchema, +} from "../../../src/adapters/cursor/gen/agent_pb"; +import { removeTreeWithRetry } from "../../helpers/remove-tree"; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) removeTreeWithRetry(root); +}); + +function fixture(files: Record = {}): string { + const root = mkdtempSync(join(tmpdir(), "ocx-cursor-grep-globs-")); + roots.push(root); + for (const [path, content] of Object.entries(files)) { + const file = join(root, path); + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, content); + } + return root; +} + +async function grep(root: string, glob?: string, outputMode = "files_with_matches", allow = true) { + const frames = await handleCursorNativeExec(create(ExecServerMessageSchema, { + id: 17, + execId: "grep-glob-test", + message: { case: "grepArgs", value: create(GrepArgsSchema, { pattern: "needle", path: root, glob, outputMode }) }, + }), { unsafeAllowNativeLocalExec: allow }); + const response = fromBinary(AgentClientMessageSchema, frames[0]!); + if (response.message.case !== "execClientMessage" || response.message.value.message.case !== "grepResult") { + throw new Error("Expected native grep response"); + } + return response.message.value.message.value.result; +} + +async function matchedFiles(root: string, glob?: string): Promise { + const response = await grep(root, glob); + if (response.case !== "success") throw new Error("Expected successful native grep"); + const result = Object.values(response.value.workspaceResults)[0]!.result; + if (result.case !== "files") throw new Error("Expected native grep file list"); + return result.value.files.map(file => relative(root, file).split(sep).join("/")).sort(); +} + +describe("Cursor native grep globs", () => { + test("matches recursive and root-relative patterns without matching absolute ancestors", async () => { + const root = fixture({ + "main.ts": "needle", + "src/main.ts": "needle", + "src/deep/worker.ts": "needle", + "other/src/wrong.ts": "needle", + "src/notes.txt": "needle", + }); + expect(await matchedFiles(root, "**/*.ts")).toEqual([ + "main.ts", "other/src/wrong.ts", "src/deep/worker.ts", "src/main.ts", + ]); + expect(await matchedFiles(root, "src/*.ts")).toEqual(["src/main.ts"]); + expect(await matchedFiles(root, "src/**/*.ts")).toEqual(["src/deep/worker.ts", "src/main.ts"]); + expect(await matchedFiles(root, "./src/**/*.ts")).toEqual(["src/deep/worker.ts", "src/main.ts"]); + expect(await matchedFiles(root, "./*.ts")).toEqual(["main.ts"]); + expect(await matchedFiles(root, `${root.replaceAll(sep, "/")}/*.ts`)).toEqual([]); + }); + + test("matches basename patterns at every depth and preserves the unfiltered search", async () => { + const root = fixture({ "main.ts": "needle", "nested/main.ts": "needle", "nested/main.ts.bak": "needle", "note.txt": "needle" }); + expect(await matchedFiles(root, "*.ts")).toEqual(["main.ts", "nested/main.ts"]); + expect(await matchedFiles(root, "main.ts")).toEqual(["main.ts", "nested/main.ts"]); + expect(await matchedFiles(root, "./main.ts")).toEqual(["main.ts"]); + expect(await matchedFiles(root)).toEqual(["main.ts", "nested/main.ts", "nested/main.ts.bak", "note.txt"]); + expect(await matchedFiles(root, "")).toEqual(await matchedFiles(root)); + }); + + test("supports character classes and brace alternatives", async () => { + const root = fixture({ "a.ts": "needle", "b.tsx": "needle", "c.js": "needle", "nested/d.ts": "needle" }); + expect(await matchedFiles(root, "?.{ts,tsx}")).toEqual(["a.ts", "b.tsx", "nested/d.ts"]); + expect(await matchedFiles(root, "[ab].*")).toEqual(["a.ts", "b.tsx"]); + }); + + // Bun 1.4.3 is still the repository's test runner; these runtime features arrived in 1.4.4. + test.skipIf(!Bun.semver.satisfies(Bun.version, ">=1.4.4"))("uses Bun 1.4.4 extglobs and brace ranges", async () => { + const root = fixture({ + "src/item-1.ts": "needle", "src/item-2.ts": "needle", "src/item-3.ts": "needle", + "src/item-4.ts": "needle", "src/item-2.test.ts": "needle", "src/deep/item-2.ts": "needle", + }); + expect(await matchedFiles(root, "src/item-{1..3}.ts")).toEqual(["src/item-1.ts", "src/item-2.ts", "src/item-3.ts"]); + expect(await matchedFiles(root, "src/**/!(*.test).ts")).toEqual([ + "src/deep/item-2.ts", "src/item-1.ts", "src/item-2.ts", "src/item-3.ts", "src/item-4.ts", + ]); + expect(await matchedFiles(root, "@(item-1|item-4).ts")).toEqual(["src/item-1.ts", "src/item-4.ts"]); + }); + + test("retains the opt-in gate for native grep", async () => { + const root = fixture({ "main.ts": "needle" }); + expect((await grep(root, "**/*.ts", "files_with_matches", false)).case).toBe("error"); + expect(await matchedFiles(root, "**/*.ts")).toEqual(["main.ts"]); + }); + + test("preserves explicit single-file searches without applying a directory glob", async () => { + const root = fixture({ "main.ts": "needle" }); + const file = join(root, "main.ts"); + const response = await grep(file, "*.js"); + if (response.case !== "success") throw new Error("Expected successful native grep"); + const result = Object.values(response.value.workspaceResults)[0]!.result; + if (result.case !== "files") throw new Error("Expected native grep file list"); + expect(result.value.files).toEqual([file]); + }); + + test("keeps traversal skips, the file byte limit, and the selected search root", async () => { + const base = fixture({ + "search/src/main.ts": "needle", + "search/node_modules/dep.ts": "needle", + "search/.git/private.ts": "needle", + "search/large.ts": `needle${"x".repeat(1_000_000)}`, + "outside/other.ts": "needle", + }); + const root = join(base, "search"); + expect(await matchedFiles(root, "**/*.ts")).toEqual(["src/main.ts"]); + expect(await matchedFiles(root, "../outside/*.ts")).toEqual([]); + }); + + test.skipIf(process.platform === "win32")("does not follow directory or file symlinks while filtering", async () => { + const base = fixture({ "search/main.ts": "needle", "outside/other.ts": "needle" }); + const root = join(base, "search"); + symlinkSync(join(base, "outside"), join(root, "linked")); + symlinkSync(join(base, "outside", "other.ts"), join(root, "linked.ts")); + expect(await matchedFiles(root, "**/*.ts")).toEqual(["main.ts"]); + }); + + test.each(["content", "count"])("applies globs to %s output", async outputMode => { + const root = fixture({ "src/main.ts": "needle\nneedle", "src/no.txt": "needle" }); + const response = await grep(root, "src/**/*.ts", outputMode); + if (response.case !== "success") throw new Error("Expected successful native grep"); + const result = Object.values(response.value.workspaceResults)[0]!.result; + expect(result.case).toBe(outputMode); + if (result.case === "content") { + expect(result.value.matches.map(match => match.file)).toEqual([join(root, "src", "main.ts")]); + expect(result.value.totalMatchedLines).toBe(2); + } else if (result.case === "count") { + expect(result.value.counts.map(match => ({ file: match.file, count: match.count }))).toEqual([{ file: join(root, "src", "main.ts"), count: 2 }]); + expect(result.value.totalFiles).toBe(1); + expect(result.value.totalMatches).toBe(2); + } + }); +}); From 8e18e6018c518723ea942fad2e960307752b21d5 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Mon, 26 Oct 2026 04:48:16 +0000 Subject: [PATCH 067/101] test(launcher): copy only Node launcher modules into runtime selection fixtures (#7733) The PATH-fallback fixture copied the whole TypeScript src tree; the Node launcher only needs its own .mjs import closure (26 modules including src/service/state-record.mjs), copied with a repository-escape guard. A comment on launcherFallbackFixture records which modes share the warmed PATH Bun and which execute a fresh copy via the fixture's synthetic install.js. --- tests/cli/ocx-launcher-runtime.test.ts | 37 +++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/tests/cli/ocx-launcher-runtime.test.ts b/tests/cli/ocx-launcher-runtime.test.ts index af4ac15d344..55c29c25eb5 100644 --- a/tests/cli/ocx-launcher-runtime.test.ts +++ b/tests/cli/ocx-launcher-runtime.test.ts @@ -1,16 +1,16 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { spawn, spawnSync, type ChildProcess } from "node:child_process"; -import { chmodSync, copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, realpathSync, readlinkSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, readlinkSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; import { createServer } from "node:net"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { bundledBunPath } from "../../src/lib/bun-runtime"; import { findDesktopCli, findPathBun, type PathBunIo } from "../../src/lib/bun-path-runtime.mjs"; import { REAL_BUN_MIN_BYTES } from "../../src/lib/bun-binary-validator.mjs"; import { killProxy } from "../../src/lib/process-control"; import { repoPath } from "../helpers/repo-root"; -import { COLD_SPAWN_WARMUP_HOOK_BUDGET_MS, warmColdSpawn } from "../helpers/cold-spawn-warmup"; +import { COLD_SPAWN_WARMUP_HOOK_BUDGET_MS, moduleGraphSpecifiers, warmColdSpawn } from "../helpers/cold-spawn-warmup"; import { INTERNAL_DEADLINE_MS, SPAWN_BUDGET_MS } from "../helpers/test-budget"; import { trustedFixtureRoot } from "../helpers/trusted-fixture-root"; @@ -788,16 +788,45 @@ async function createWarmPathBun(): Promise { }); } +/** The Node launcher needs its imported modules, not the whole TypeScript proxy tree. */ +function copyLauncherModuleFixture(root: string): void { + const repository = repoPath(); + const pending = moduleGraphSpecifiers(readFileSync(BIN_OCX, "utf8"), dirname(BIN_OCX)); + const copied = new Set(); + while (pending.length) { + const source = pending.pop()!; + if (copied.has(source)) continue; + const path = relative(repository, source); + if (isAbsolute(path) || path === ".." || path.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`)) { + throw new Error("launcher fixture import escapes the repository"); + } + const destination = join(root, path); + mkdirSync(dirname(destination), { recursive: true }); + copyFileSync(source, destination); + copied.add(source); + if (source.endsWith(".mjs")) { + pending.push(...moduleGraphSpecifiers(readFileSync(source, "utf8"), dirname(source))); + } + } +} + +// Every mode runs the real Node launcher: missing/broken/inspection/override +// select the PATH Bun copy warmed once in beforeAll, while bundled and repair +// use a fresh copy of the real ~100MB binary in this fixture's own node_modules; +// repair gets it from the fixture's synthetic install.js running as a real Node +// child, and bundled and repair then execute their new copy. Unchanged limits: +// the launcher's 5s PATH-Bun probe and the 30s spawnSync bound below. function launcherFallbackFixture(mode: "missing" | "bundled" | "repair" | "broken" | "override" | "none" | "inspection" | "rejected") { const root = realpathSync.native(pathFixtureRoot("ocx-path-launcher-")); try { const node = spawnSync("node", ["-p", "process.execPath"], { encoding: "utf8", windowsHide: true }).stdout.trim(); const version = spawnSync(process.execPath, ["--version"], { encoding: "utf8", windowsHide: true }).stdout.trim(); expect(version).toMatch(/^1\.(?:[4-9]|[1-9]\d+)\./); - cpSync(repoPath("src"), join(root, "src"), { recursive: true }); + copyLauncherModuleFixture(root); mkdirSync(join(root, "bin")); copyFileSync(BIN_OCX, join(root, "bin", "ocx.mjs")); writeFileSync(join(root, "package.json"), JSON.stringify({ version: "2.81.0", dependencies: { bun: "1.4.2" } })); + mkdirSync(join(root, "src", "cli"), { recursive: true }); writeFileSync(join(root, "src", "cli", "index.ts"), 'console.log(JSON.stringify({source:process.env.OCX_BUN_RUNTIME_SOURCE,path:process.env.OCX_BUN_RUNTIME_PATH}));'); const shared = mode !== "none" && mode !== "rejected"; if (shared && !sharedPathBun) throw new Error("the shared PATH Bun is created by the activation suite's beforeAll"); From 88c17bcf14498e588871dca325bedd672fc6b7f5 Mon Sep 17 00:00:00 2001 From: Fred Amartey <43480311+FredAmartey@users.noreply.github.com> Date: Mon, 26 Oct 2026 01:05:25 -0400 Subject: [PATCH 068/101] fix(responses): notice a Codex WebSocket that dies after the response started (#7725) The exchange stopped pinging once the first Responses event committed the 200, so a path that died without a FIN or RST kept the committed stream silent until codex-rs's 300 s stream_idle_timeout. Keep the pinger for the whole exchange, send one probe with the create frame, and end a committed stream whose peer answered a ping and then sent no frame and no pong for two intervals. --- .../docs/reference/codex-retry-diagnostics.md | 33 +++ scripts/test-layout/layout.json | 2 +- src/server/responses/codex-ws-exchange.ts | 48 +++- src/server/responses/codex-ws-wire.ts | 7 +- structure/transports/responses-wire-shapes.md | 2 +- tests/fixtures/test-layout-expected.json | 2 +- tests/responses/ws-committed-liveness.test.ts | 230 ++++++++++++++++++ tests/responses/ws-upstream.test.ts | 8 +- 8 files changed, 315 insertions(+), 17 deletions(-) create mode 100644 tests/responses/ws-committed-liveness.test.ts diff --git a/docs-site/src/content/docs/reference/codex-retry-diagnostics.md b/docs-site/src/content/docs/reference/codex-retry-diagnostics.md index d2e2ee8c2e3..a4eb1ec9722 100644 --- a/docs-site/src/content/docs/reference/codex-retry-diagnostics.md +++ b/docs-site/src/content/docs/reference/codex-retry-diagnostics.md @@ -48,6 +48,39 @@ that the account pool routed to the main account, the earliest known time anothe pool account recovers, whichever comes first (never sooner than one minute). Resume the Goal after that time; routing then picks an account from fresh state. +## Triage: `idle timeout waiting for SSE` + +Codex ends a stream that has sent no event for its `stream_idle_timeout_ms` +(300 seconds by default) and retries the turn. The report alone does not say +why the stream went quiet. On the native ChatGPT upstream WebSocket, one +possible cause is a path that failed silently after the response started: a VPN +or tunnel restart, a Wi-Fi rejoin or an expired NAT mapping can drop it without +a FIN or RST reaching the proxy. + +On the canonical ChatGPT Codex endpoint +(`https://chatgpt.com/backend-api/codex/responses`, the exchange with the +metadata channel), the proxy pings the upstream for the whole exchange. Once the +response has started, a peer that has answered at least one ping on that +exchange and then delivers no frame and no pong for two ping intervals ends the +stream with a retryable failure. Codex then reconnects 30 to 45 seconds after +the last frame or pong instead of after 300 seconds, and the reconnect row names +the cause: + +```text +Reconnecting... 1/5 (stream disconnected before completion: Upstream stream terminated unexpectedly: codex websocket stopped answering after the response started [cause=after-response-started ...]) +``` + +When a ping reaches a path that a restarted tunnel resets, the same row reports +`codex websocket closed before a Responses terminal event (close 1006)` +instead, within about one ping interval. An `idle timeout waiting for SSE` that +still arrives after the full timeout comes from a peer that kept answering pings +while sending no events, a peer that never answered a ping, or an exchange +without the metadata channel, such as the OpenAI API Responses WebSocket, which +sends no pings. All three keep the client's idle deadline on purpose. +`ocx logs get --json` shows the attempt's `codexWsStage` with its +`pings` and `pongs` counts, which tell the three apart. Raising +`stream_idle_timeout_ms` only makes them wait longer. + ## Why the first reconnect row can be absent The public Codex `rust-v0.158.0-alpha.2.1` source gates the ordinary stream-retry diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 2fdded5a374..f3a4bcc5fd8 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1953,7 +1953,7 @@ "ws-capacity-reclamation.test.ts": "responses", "ws-capacity-tool-ownership.test.ts": "responses", "ws-endpoint.test.ts": "responses", - "ws-failure-stage.test.ts": "responses", + "ws-failure-stage.test.ts": "responses", "ws-committed-liveness.test.ts": "responses", "ws-native-injection.test.ts": "responses", "ws-native-result-continuations.test.ts": "responses", "ws-native-steering.test.ts": "responses", diff --git a/src/server/responses/codex-ws-exchange.ts b/src/server/responses/codex-ws-exchange.ts index 3e09e434c79..11e0c8b5422 100644 --- a/src/server/responses/codex-ws-exchange.ts +++ b/src/server/responses/codex-ws-exchange.ts @@ -8,7 +8,8 @@ import { CodexWsMetadata, type CodexWsQuotaObserver } from "./codex-ws-metadata" import { CODEX_RESPONSES_HTTP_URL, type PreparedCodexWsRequest } from "./codex-ws-request"; import { CodexWsCorrelation } from "./codex-ws-correlation"; import type { CodexWsSession } from "./codex-ws-session"; -import { UPGRADE_DEADLINE_MS, CODEX_WS_LIVENESS_PING_INTERVAL_MS, CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS, MAX_CODEX_WS_FRAME_BYTES, +import { UPGRADE_DEADLINE_MS, CODEX_WS_LIVENESS_PING_INTERVAL_MS, CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS, + CODEX_WS_COMMITTED_SILENT_INTERVALS, MAX_CODEX_WS_FRAME_BYTES, MAX_CODEX_WS_QUEUE_BYTES, markCodexWsResponse, markCodexWsPreludeProjection, normalizeResponsesWsRelayEvent, closedBeforeTerminalMessage, codexWsCreateFrameExceedsLimit, codexWsFailureDetail, codexWsPreResponseFailure, markCodexWsStage, codexWsOcxVersion, markCodexWsSocketDeath, codexWsWrapperStatus, isCodexWsTerminalEvent, type CodexWsFailureStage, type CodexWsStageRecord, @@ -155,8 +156,11 @@ export function codexWsExchange(options: ExchangeOptions): Promise { // Liveness while waiting for the first response event (metadata path only): the // silence timer is re-armed by every inbound frame or pong; the pinger runs on a fixed // interval so a peer that answers pings can never trip the silence bound while alive. + // After the commit, each ping tick checks whether any frame or pong arrived since the last. let silenceTimer: ReturnType | undefined; let pingTimer: ReturnType | undefined; + let inboundAtLastTick = 0; + let silentIntervals = 0; // The resolved 200, retained so a later body failure can replace its // success-shaped stage record with the failure-shaped one (#4191). let committedResponse: Response | null = null; @@ -260,6 +264,7 @@ export function codexWsExchange(options: ExchangeOptions): Promise { markCodexWsStage(response, stageRecord(null)); committedResponse = response; resolve(response); + startCommittedLiveness(); }; /** @@ -377,18 +382,40 @@ export function codexWsExchange(options: ExchangeOptions): Promise { CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS, ); }; - /** Ping on a fixed interval until the response starts; a socket without ping() is never pinged. */ + const pingable = () => typeof (ws as WebSocket & { ping?: unknown }).ping === "function"; + /** False when ping() threw. It may also close the socket and settle the exchange. */ + const sendPing = (): boolean => { + if (!pingable()) return false; + try { (ws as WebSocket & { ping: (data?: string) => void }).ping(); } catch { return false; } + pings += 1; + return true; + }; + /** Ping on a fixed interval for the life of the exchange; a socket without ping() is never pinged. */ const schedulePing = () => { - const ping = (ws as WebSocket & { ping?: (data?: string) => void }).ping; - if (typeof ping !== "function" || responseCommitted || terminal) return; + if (!pingable() || terminal) return; pingTimer = setTimeout(() => { - if (responseCommitted || terminal) return; - try { ping.call(ws); } catch { return; } - pings += 1; - // ping() may close the socket synchronously and settle the exchange; re-check. + if (terminal) return; + if (responseCommitted && pongs > 0) { + const inbound = upstreamFrames + pongs; + silentIntervals = inbound === inboundAtLastTick ? silentIntervals + 1 : 0; + inboundAtLastTick = inbound; + if (silentIntervals >= CODEX_WS_COMMITTED_SILENT_INTERVALS) { + failStream(`codex websocket stopped answering after the response started${codexWsFailureDetail(failureStage())}`); + return; + } + } + sendPing(); if (!terminal) schedulePing(); }, CODEX_WS_LIVENESS_PING_INTERVAL_MS); }; + /** Only a peer that has ponged is judged by its silence, so probe again if none has yet. */ + const startCommittedLiveness = () => { + if (!metadata || terminal || !pingable()) return; + inboundAtLastTick = upstreamFrames + pongs; + silentIntervals = 0; + if (pongs === 0) sendPing(); + if (!terminal) schedulePing(); + }; const onPong = () => { if (terminal) return; pongs += 1; @@ -559,7 +586,10 @@ export function codexWsExchange(options: ExchangeOptions): Promise { if (!metadata) commitResponse(); else if (!responseCommitted && !terminal) { armSilence(); - schedulePing(); + // Probe now, not one interval in: the path can die seconds after the send, before a + // held commit, and the committed check needs a pong from before that. + sendPing(); + if (!terminal) schedulePing(); } }; diff --git a/src/server/responses/codex-ws-wire.ts b/src/server/responses/codex-ws-wire.ts index 4327d7662ee..77065d2af28 100644 --- a/src/server/responses/codex-ws-wire.ts +++ b/src/server/responses/codex-ws-wire.ts @@ -25,6 +25,11 @@ export const UPGRADE_DEADLINE_MS = 10_000; // keeps exactly the previous 90 s bound; a peer that does can never trip it while alive. export const CODEX_WS_LIVENESS_PING_INTERVAL_MS = 15_000; export const CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS = 90_000; +// The pinger keeps running after the commit: a tunnel restart or Wi-Fi rejoin can drop the path +// without a FIN or RST, and codex-rs would wait out its 300 s idle timeout. A peer that has ponged +// and then sends no frame and no pong for this many intervals is gone, and the stream fails so the +// client retries. One missed pong is not a verdict. +export const CODEX_WS_COMMITTED_SILENT_INTERVALS = 2; // Keep the push-based WS transport inside the same memory envelope as the // bounded SSE relays that consume this response. Unlike fetch response bodies, // a WebSocket cannot be paused when a ReadableStream applies backpressure, so @@ -404,7 +409,7 @@ export type CodexWsFailureStage = { firstResponseMs: number | null; /** Milliseconds from send to this failure; null when the failure predates the send. */ elapsedMs: number | null; - /** Liveness pings the exchange sent while waiting for the first response event. */ + /** Liveness pings the exchange sent, before and after the first response event. */ pings: number; /** Pongs the peer answered with; zero on a peer that never answers pings. */ pongs: number; diff --git a/structure/transports/responses-wire-shapes.md b/structure/transports/responses-wire-shapes.md index 1578413634c..4bbe49be3e4 100644 --- a/structure/transports/responses-wire-shapes.md +++ b/structure/transports/responses-wire-shapes.md @@ -480,7 +480,7 @@ A standalone no-response exchange has a 90-second prelude deadline in addition to the upgrade deadline. That prelude deadline is a ceiling, not a floor: the exchange runs under the caller's abort signal, so a `connectTimeoutMs` shorter than 90 seconds cancels -an already-sent create before the prelude timer fires. +an already-sent create before the prelude timer fires. After the first Responses event an exchange with the metadata channel keeps pinging on the same interval, and sends one ping at the commit unless an earlier one was answered; a peer that has answered a ping on that exchange and then delivers no frame and no pong for two consecutive intervals is treated as a dead socket, so the committed stream fails as a body error that the relay ends with its `upstream_reset` `response.failed` tail. This check never cuts a peer that keeps answering, however long the model is silent; that peer, like one that never answered a ping, still meets the client's own idle deadline (`tests/responses/ws-committed-liveness.test.ts`). These are transport-fidelity guarantees, not a provider-billing guarantee. Every exchange also leaves a content-free stage record (`CodexWsStageRecord`, #4191): create-frame bytes (measured on failure only — the committed-success record keeps it null so the happy path never byte-counts a megabyte replay frame), send completion, numeric close code, elapsed, first-frame and first-response-event durations, frame counters, liveness ping/pong counts, pool reuse, the OCX/Bun versions, and (#6740) `terminalEvent` — the normalized `response.completed` / `response.failed` / `response.incomplete` / `error` frame that ended a plain exchange, observed before any synthetic tail — with `terminalErrorStatus`, the wrapper status of a terminal `error` frame. Both are null when no terminal frame arrived, on native-control exchanges (which can end on an acknowledgement), and on rows written before they existed; malformed persisted values read as null without dropping the stage. The exchange pins the record on the resolved Response (`markCodexWsStage`, the same marker seam as `markCodexWsResponse`); `handleResponses` adopts it onto the serving attempt, and usage.jsonl persists it per attempt behind a drop-guard normalizer, so hand-edited rows cannot inject strings into the DTO. Later snapshots update the same response-local record in place, so an attempt holding the committed reference observes final success or failure counters. Each exchange supplies a complete fresh snapshot; separate responses keep distinct records. On eager-relay cancel-drain expiry, upstream cancellation finalizes the transport snapshot before the cancellation hook writes the usage row; an actual terminal observed within the drain still wins over cancellation. The record never carries conversation text, headers, close-reason text, or account identifiers, and it is not a fallback-eligibility signal: nothing it says permits a resend. The one replacement an operator can grant after a socket dies is the resend gate's decision (see [ambiguous-resend gate](responses-failover.md#ambiguous-resend-gate)). diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index c05bb1d4388..31970b8bde5 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1341,7 +1341,7 @@ "ws-capacity-reclamation.test.ts": "responses", "ws-capacity-tool-ownership.test.ts": "responses", "ws-endpoint.test.ts": "responses", - "ws-failure-stage.test.ts": "responses", + "ws-failure-stage.test.ts": "responses", "ws-committed-liveness.test.ts": "responses", "ws-native-injection.test.ts": "responses", "ws-native-result-continuations.test.ts": "responses", "ws-native-steering.test.ts": "responses", diff --git a/tests/responses/ws-committed-liveness.test.ts b/tests/responses/ws-committed-liveness.test.ts new file mode 100644 index 00000000000..3b80460a6a3 --- /dev/null +++ b/tests/responses/ws-committed-liveness.test.ts @@ -0,0 +1,230 @@ +import { afterEach, beforeEach, describe, expect, jest, test } from "bun:test"; +import { CODEX_WS_LIVENESS_PING_INTERVAL_MS, codexWsUpstreamFetch } from "../../src/server/responses/ws-upstream"; +import { codexWsPool } from "../../src/server/responses/codex-ws-pool"; +import { BOUNDED_WS_RUNTIME, streamingInit } from "../helpers/ws-upstream-fixtures"; + +/** + * Pinging used to stop at the commit, so a path that died after `response.created` left the stream + * silent until codex-rs's 300 s idle timeout. A peer that ponged and then goes silent is now gone; + * one that keeps answering, or never answered, keeps the stream as before. + */ + +const CODEX_URL = "https://chatgpt.com/backend-api/codex/responses"; +const INTERVAL = CODEX_WS_LIVENESS_PING_INTERVAL_MS; + +type Listener = (event: unknown) => void; + +/** Minimal scriptable stand-in for Bun's WebSocket, mirroring `ws-pre-output-refusal.test.ts`. */ +class FakeWebSocket { + static instances: FakeWebSocket[] = []; + static script: (ws: FakeWebSocket) => void = () => {}; + url: string; + sent: string[] = []; + pings = 0; + answersPings = true; + pingThrows = false; + closed = false; + listeners = new Map(); + + constructor(url: string) { + this.url = url; + FakeWebSocket.instances.push(this); + queueMicrotask(() => FakeWebSocket.script(this)); + } + + addEventListener(type: string, listener: Listener) { + const list = this.listeners.get(type) ?? []; + list.push(listener); + this.listeners.set(type, list); + } + + removeEventListener(type: string, listener: Listener) { + this.listeners.set(type, (this.listeners.get(type) ?? []).filter(value => value !== listener)); + } + + emit(type: string, event: unknown = {}) { + for (const listener of this.listeners.get(type) ?? []) listener(event); + } + + frame(payload: unknown) { + this.emit("message", { data: JSON.stringify(payload) }); + } + + ping() { + if (this.pingThrows) throw new Error("WebSocket is not open"); + this.pings += 1; + if (this.answersPings) this.emit("pong", {}); + } + + send(data: string) { + this.sent.push(data); + } + + close() { this.closed = true; } +} + +const RealWebSocket = globalThis.WebSocket; + +beforeEach(() => { + FakeWebSocket.instances = []; + FakeWebSocket.script = () => {}; + jest.useFakeTimers(); +}); + +afterEach(() => { + jest.useRealTimers(); + globalThis.WebSocket = RealWebSocket; + codexWsPool.dispose(); +}); + +const noFallback = (async () => { + throw new Error("fallback must not run after open"); +}) as unknown as typeof fetch; + +const RATE_LIMITS = { type: "codex.rate_limits", rate_limits: { primary: { used_percent: 10, window_minutes: 10080 } } }; +const CREATED = { type: "response.created", response: { id: "resp_1", status: "in_progress", output: [] } }; +const IN_PROGRESS = { type: "response.in_progress", response: { id: "resp_1", status: "in_progress", output: [] } }; +const SUMMARY_DELTA = { type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "…" }; +const COMPLETED = { type: "response.completed", response: { id: "resp_1", status: "completed", output: [] } }; + +const flush = async () => { + for (let turn = 0; turn < 20; turn++) await Promise.resolve(); +}; + +/** + * Quota, an empty preamble, then nothing: the shape that hit the 300 s timeout. `holdMs` turns on + * the Pool pre-output hold; `diesInPreamble` kills the path before that hold commits. + */ +async function committedExchange(answersPings: boolean, { holdMs = 0, diesInPreamble = false } = {}) { + globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket; + FakeWebSocket.script = ws => { + ws.answersPings = answersPings; + ws.emit("open", {}); + ws.frame(RATE_LIMITS); + ws.frame(CREATED); + ws.frame(IN_PROGRESS); + if (diesInPreamble) ws.answersPings = false; + }; + const hold = holdMs > 0 ? { maxMs: holdMs, until: Number.MAX_SAFE_INTEGER } : undefined; + const pending = codexWsUpstreamFetch( + CODEX_URL, streamingInit(), noFallback, BOUNDED_WS_RUNTIME, + undefined, undefined, undefined, undefined, undefined, hold, + ); + if (hold) { + await flush(); + jest.advanceTimersByTime(holdMs); + } + const response = await pending; + expect(response.status).toBe(200); + const ws = FakeWebSocket.instances[0]!; + // A reader, not response.text(): its reads settle on microtasks, so fake timers can step them. + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let outcome: { text: string } | { error: string } | undefined; + void (async () => { + let text = ""; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + text += decoder.decode(value, { stream: true }); + } + outcome = { text }; + } catch (error) { + outcome = { error: error instanceof Error ? error.message : String(error) }; + } + })(); + /** One ping interval per step, so each chained timer is scheduled and fired. */ + const advance = async (intervals: number, each?: () => void) => { + for (let step = 0; step < intervals; step++) { + jest.advanceTimersByTime(INTERVAL); + each?.(); + await flush(); + } + await flush(); + }; + return { ws, advance, outcome: () => outcome }; +} + +describe("codex ws committed-stream liveness", () => { + test("a socket that stops answering after the response started is settled long before the client's idle timeout", async () => { + const { ws, advance, outcome } = await committedExchange(true); + // The path dies without a close. + ws.answersPings = false; + await advance(1); + // One unanswered ping is not a verdict. + expect(outcome()).toBeUndefined(); + await advance(2); + const settled = outcome(); + expect(settled).toBeDefined(); + if (!settled || !("error" in settled)) throw new Error(`expected a body failure, got ${JSON.stringify(settled)}`); + expect(settled.error).toContain("stopped answering after the response started"); + expect(settled.error).toContain("cause=after-response-started"); + expect(settled.error).toMatch(/pongs=1\]/); + expect(ws.closed).toBe(true); + expect(ws.sent).toHaveLength(1); + }); + + test("a socket whose ping() starts throwing is still judged by its silence", async () => { + const { ws, advance, outcome } = await committedExchange(true); + ws.pingThrows = true; + await advance(3); + const settled = outcome(); + if (!settled || !("error" in settled)) throw new Error(`expected a body failure, got ${JSON.stringify(settled)}`); + expect(settled.error).toContain("stopped answering after the response started"); + }); + + test("a peer that keeps answering pings is never cut, however long the model stays silent", async () => { + const { ws, advance, outcome } = await committedExchange(true); + // 300 s without a message frame, every ping answered. + await advance(20); + expect(outcome()).toBeUndefined(); + ws.frame(COMPLETED); + await advance(0); + const settled = outcome(); + if (!settled || !("text" in settled)) throw new Error(`expected a completed body, got ${JSON.stringify(settled)}`); + expect(settled.text).toContain("event: response.completed"); + // The pinger ends with the exchange. + const pings = ws.pings; + await advance(4); + expect(ws.pings).toBe(pings); + expect([...ws.listeners.values()].every(listeners => listeners.length === 0)).toBe(true); + }); + + test("frames are proof of life on their own: a streaming peer that stopped answering pings is not cut", async () => { + const { ws, advance, outcome } = await committedExchange(true); + ws.answersPings = false; + await advance(10, () => ws.frame(SUMMARY_DELTA)); + expect(outcome()).toBeUndefined(); + ws.frame(COMPLETED); + await advance(0); + const settled = outcome(); + if (!settled || !("text" in settled)) throw new Error(`expected a completed body, got ${JSON.stringify(settled)}`); + expect(settled.text).toContain("event: response.completed"); + }); + + test("a peer that has never answered a ping keeps the committed stream exactly as before", async () => { + const { ws, advance, outcome } = await committedExchange(false); + await advance(20); + // No pong ever came back, so silence proves nothing. + expect(outcome()).toBeUndefined(); + expect(ws.closed).toBe(false); + ws.frame(COMPLETED); + await advance(0); + const settled = outcome(); + if (!settled || !("text" in settled)) throw new Error(`expected a completed body, got ${JSON.stringify(settled)}`); + expect(settled.text).toContain("event: response.completed"); + }); + + test("a path that dies while the pre-output hold keeps the 200 back is still caught after the commit", async () => { + // The path dies seconds after the send, before a 10 s hold commits. + const { ws, advance, outcome } = await committedExchange(true, { holdMs: 10_000, diesInPreamble: true }); + await advance(1); + expect(outcome()).toBeUndefined(); + await advance(1); + const settled = outcome(); + if (!settled || !("error" in settled)) throw new Error(`expected a body failure, got ${JSON.stringify(settled)}`); + expect(settled.error).toContain("stopped answering after the response started"); + expect(ws.closed).toBe(true); + }); +}); diff --git a/tests/responses/ws-upstream.test.ts b/tests/responses/ws-upstream.test.ts index 8079c64309f..9d587853a5f 100644 --- a/tests/responses/ws-upstream.test.ts +++ b/tests/responses/ws-upstream.test.ts @@ -1558,9 +1558,9 @@ describe("codexWsUpstreamFetch", () => { const pending = codexWsUpstreamFetch(CODEX_URL, streamingInit(), noResend(counter)); await opened.promise; const ws = FakeWebSocket.instances[0]; - // Seven steps: 105 s of no message frames, well past the 90 s bound, every step ponged. + // Seven steps plus the send probe: 105 s of no message frames, past the 90 s bound. for (let step = 0; step < 7; step++) jest.advanceTimersByTime(CODEX_WS_LIVENESS_PING_INTERVAL_MS); - expect(pingsSent).toBe(7); + expect(pingsSent).toBe(8); ws.emit("message", { data: JSON.stringify({ type: "response.created", response: { id: "r1" } }) }); ws.emit("message", { data: JSON.stringify({ type: "response.completed", response: { id: "r1" } }) }); const response = await pending; @@ -1568,9 +1568,9 @@ describe("codexWsUpstreamFetch", () => { expect(await response.text()).toContain("event: response.completed"); expect(ws.sent).toHaveLength(1); expect(counter.http).toBe(0); - // The pinger stops once the response has started. + // The pinger stops once the exchange settles. jest.advanceTimersByTime(CODEX_WS_LIVENESS_PING_INTERVAL_MS * 4); - expect(pingsSent).toBe(7); + expect(pingsSent).toBe(8); expect([...ws.listeners.values()].every(listeners => listeners.length === 0)).toBe(true); } finally { jest.useRealTimers(); From c2f3d8b42be105ca8ddd9510b13cf350656f01b8 Mon Sep 17 00:00:00 2001 From: Plamyth <145187149+Plamyth@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:06:09 +0900 Subject: [PATCH 069/101] fix(codex): clamp native efforts to selected model ladder (#7731) * test(codex): reproduce unsupported none effort for native model * fix(codex): clamp native efforts to selected model ladder --- .../docs/reference/configuration/agents.md | 3 ++ src/codex/catalog/effort.ts | 16 +++--- structure/catalog.md | 2 +- structure/transports/responses-failover.md | 5 +- .../codex-catalog-ladders.test.ts | 4 ++ .../responses-compaction-override.test.ts | 53 +++++++++++++++++++ 6 files changed, 73 insertions(+), 10 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/agents.md b/docs-site/src/content/docs/reference/configuration/agents.md index 58ba51828c5..d03750291a2 100644 --- a/docs-site/src/content/docs/reference/configuration/agents.md +++ b/docs-site/src/content/docs/reference/configuration/agents.md @@ -384,6 +384,9 @@ Synthetic-row effort and combo defaults are preserved as the effective input bef Each selected destination resolves its own pin, then applicable caps and wire normalization. Compaction requests are exempt. `none` means effort omission and provider-default behavior, not guaranteed reasoning disablement. +For canonical OpenAI forwarding, the final model's supported effort list takes precedence: +`gpt-6.1-sol` starts at `low`, so an incoming `none` or `minimal` is sent as `low`, including +after a compaction model switch or combo failover. `GET /api/effort-caps` includes the map. `PUT /api/effort-caps` accepts `modelPinnedEfforts` alongside the existing caps: omitted fields stay unchanged, `null` clears the map, and a map diff --git a/src/codex/catalog/effort.ts b/src/codex/catalog/effort.ts index 2c8aef49a78..87f9dd0cde2 100644 --- a/src/codex/catalog/effort.ts +++ b/src/codex/catalog/effort.ts @@ -53,19 +53,20 @@ import { } from "../runtime"; export function nativeEffortClamp(slug: string, effort: string | undefined): string | null { - if (!effort || (effort !== "max" && effort !== "ultra")) return null; + if (!effort) return null; if (slug.includes("/")) return null; // routed models map efforts in their adapters // `ultra` is a client-facing catalog choice (max reasoning plus proactive delegation) that // codex-rs converts to `max` before any request (core/src/client.rs // `reasoning_effort_for_request`). The ChatGPT backend rejects it with HTTP 400 even for a // model whose pinned ladder lists it (#7590), so it is never a native wire value: fold it to // `max`, then clamp `max` like a direct selection. - const wire = "max"; + const wire = effort === "ultra" ? "max" : effort; const entry = UPSTREAM_NATIVE_ENTRIES.get(slug); const levels = Array.isArray(entry?.supported_reasoning_levels) ? entry.supported_reasoning_levels as Array<{ effort?: string }> : []; if (levels.length === 0) { + if (wire !== "max") return null; // Not snapshot-covered. gpt-5.6 natives have a REAL max rung (ensureGpt56ReasoningLevels // restores it even off-snapshot) -> never clamp. Every other bare native (gpt-5.5, // a retired slug a client still asks for, and future old-ladder slugs) @@ -76,12 +77,11 @@ export function nativeEffortClamp(slug: string, effort: string | undefined): str } const supported = levels.flatMap(l => typeof l.effort === "string" ? [l.effort] : []); if (supported.includes(wire)) return effort === wire ? null : wire; - const rank = ["minimal", "low", "medium", "high", "xhigh", "max"]; - const highest = supported - .filter(e => rank.includes(e)) - .sort((a, b) => rank.indexOf(a) - rank.indexOf(b)) - .at(-1); - return highest ?? null; + const rank = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]; + const requestedRank = rank.indexOf(wire); + if (requestedRank < 0) return null; + const available = supported.filter(e => rank.includes(e)).sort((a, b) => rank.indexOf(a) - rank.indexOf(b)); + return available.filter(e => rank.indexOf(e) <= requestedRank).at(-1) ?? available[0] ?? null; } export function shouldApplyNativeEffortClamp( diff --git a/structure/catalog.md b/structure/catalog.md index 2db414d1edc..0b7be713aa7 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -481,7 +481,7 @@ The shared Responses path follows the [bounded multipart recovery contract](suba Ultra is always advertised in the catalog regardless of the `multi_agent_v2` toggle. The v2 toggle controls only the multi-agent collab surface, not ultra visibility. The `nativeEffortClamp` function -wire-clamps ultra/max to each model's real top rung (e.g. gpt-5.5 ultra → xhigh on the wire). `ultra` itself is never a native wire value: the ChatGPT backend accepts `none` through `max`, so the clamp folds it to `max` even on rows whose pinned ladder lists it (`gpt-6-astra`, `gpt-6.1-sol`), and `src/server/responses/core-normalize.ts` applies that to the raw body the passthrough serializes, which the parser's options-only fold does not reach (#7590; `tests/responses/responses-native-ultra-effort-wire.test.ts`). +wire-clamps ultra/max to each model's real top rung (e.g. gpt-5.5 ultra → xhigh on the wire). `ultra` itself is never a native wire value: the ChatGPT backend accepts `none` through `max`, so the clamp folds it to `max` even on rows whose pinned ladder lists it (`gpt-6-astra`, `gpt-6.1-sol`), and `src/server/responses/core-normalize.ts` applies that to the raw body the passthrough serializes, which the parser's options-only fold does not reach (#7590; `tests/responses/responses-native-ultra-effort-wire.test.ts`). For canonical OpenAI forwarding, other unsupported efforts map to the nearest published rung at or below the request, or the lowest rung when below the model minimum (`gpt-6.1-sol` `none` → `low`), after final model selection, including combo failover. For routed models, `modelSuppressSyntheticMax` is a catalog-only per-model setting. A true value prevents `src/codex/catalog/effort.ts` from adding a missing synthetic `max` and prevents diff --git a/structure/transports/responses-failover.md b/structure/transports/responses-failover.md index 4dcf2311c41..e328c85e012 100644 --- a/structure/transports/responses-failover.md +++ b/structure/transports/responses-failover.md @@ -284,7 +284,10 @@ canonical `openai` provider exists (#2901), not when its quota is exhausted. A h `triggers` the schema would reject disables the whole block instead of widening it, so a malformed edit can never route more than it names. -The override changes only the model and optional reasoning effort; native forwarding, routed summaries, capability handling, and retry +The override changes only the model and optional reasoning effort. Final-route normalization +checks that effort against the selected canonical native model's published ladder before each +Responses send, including a combo fallback (`gpt-6.1-sol` `none` → `low`). Native forwarding, +routed summaries, capability handling, and retry budgets stay authoritative, and native compact still removes reasoning before sending. Handoffs carry the override record (with the source model) as a recursion guard so combo children and fallbacks keep their targets. Overrides bypass shadow interception and conversation combo recall, and do not publish replacement combo/handoff recall. They never change the conversation's model or compactions outside the diff --git a/tests/codex-integration/codex-catalog-ladders.test.ts b/tests/codex-integration/codex-catalog-ladders.test.ts index cd335a5bbd8..a183f8785db 100644 --- a/tests/codex-integration/codex-catalog-ladders.test.ts +++ b/tests/codex-integration/codex-catalog-ladders.test.ts @@ -215,6 +215,10 @@ describe("catalog ultra (always-on)", () => { }); describe("mock-max wire clamp (nativeEffortClamp)", () => { + test("native model rejects none and selects its lowest supported effort", () => { + expect(nativeEffortClamp("gpt-6.1-sol", "none")).toBe("low"); + }); + test("gpt-5.5 max/ultra clamp to its real top rung (xhigh)", () => { expect(nativeEffortClamp("gpt-5.5", "max")).toBe("xhigh"); expect(nativeEffortClamp("gpt-5.5", "ultra")).toBe("xhigh"); diff --git a/tests/responses/responses-compaction-override.test.ts b/tests/responses/responses-compaction-override.test.ts index 0fe7ed382eb..cbe8a10166a 100644 --- a/tests/responses/responses-compaction-override.test.ts +++ b/tests/responses/responses-compaction-override.test.ts @@ -293,6 +293,59 @@ describe("manual compaction config", () => { }); describe("manual compaction reuses existing handlers", () => { + test.each(["direct", "compaction switch"])("%s sends gpt-6.1-sol's supported low effort", async mode => { + const settings = config(); + settings.providers.openai = { adapter: "openai-responses", authMode: "forward", codexAccountMode: "direct", baseUrl: "https://chatgpt.com/backend-api/codex" }; + if (mode === "direct") delete settings.compactionRouting; + else { + settings.compactionRouting!.model = "gpt-6.1-sol"; + delete settings.compactionRouting!.reasoningEffort; + } + const calls: Array> = []; + globalThis.fetch = (async (_url: unknown, init?: RequestInit) => { + const outbound = JSON.parse(String(init?.body)); + calls.push(outbound); + return outbound.reasoning?.effort === "none" + ? Response.json({ error: { code: "unsupported_value", param: "reasoning.effort" } }, { status: 400 }) + : upstreamCompletion(outbound); + }) as typeof fetch; + const input = { ...body(mode !== "direct"), model: mode === "direct" ? "gpt-6.1-sol" : "gateway/normal", reasoning: { effort: "none", summary: "auto" } }; + const req = request(input, mode === "direct" ? undefined : "manual"); + req.headers.set("authorization", `Bearer ${fakeChatGptJwt({ chatgpt_account_id: "fixture-account" })}`); + const response = await handleResponses(req, settings, { model: "", provider: "" }); + await response.text(); + expect(response.status).toBe(200); + expect(calls).toHaveLength(1); + expect(calls[0]!.model).toBe("gpt-6.1-sol"); + expect(calls[0]!.reasoning).toMatchObject({ effort: "low", summary: "auto" }); + }); + + test("combo failover normalizes the second native target before its first send", async () => { + const settings = config(); + delete settings.compactionRouting; + settings.providers.openai = { adapter: "openai-responses", authMode: "forward", codexAccountMode: "direct", baseUrl: "https://chatgpt.com/backend-api/codex" }; + settings.combos = { fail: { strategy: "failover", targets: [ + { provider: "gateway", model: "normal" }, { provider: "openai", model: "gpt-6.1-sol" }, + ] } }; + const calls: Array> = []; + globalThis.fetch = (async (_url: unknown, init?: RequestInit) => { + const outbound = JSON.parse(String(init?.body)); + calls.push(outbound); + if (outbound.model === "normal") return Response.json({ error: { message: "quota", type: "rate_limit_error" } }, { status: 429 }); + return outbound.reasoning?.effort === "none" + ? Response.json({ error: { code: "unsupported_value", param: "reasoning.effort" } }, { status: 400 }) + : upstreamCompletion(outbound); + }) as typeof fetch; + const req = request({ ...body(false), model: "combo/fail", reasoning: { effort: "none", summary: "auto" } }); + req.headers.set("authorization", `Bearer ${fakeChatGptJwt({ chatgpt_account_id: "fixture-account" })}`); + const response = await handleResponses(req, settings, { model: "", provider: "" }); + await response.text(); + expect(response.status).toBe(200); + expect(calls.map(call => [call.model, call.reasoning?.effort])).toEqual([ + ["normal", "none"], ["gpt-6.1-sol", "low"], + ]); + }); + test("an ordinary turn carrying manual metadata stays on the conversation model", async () => { const settings = config(); const calls: string[] = []; From 8dd330a3f5165ea1685ad5282c5215a56dbc09f2 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Mon, 26 Oct 2026 06:08:02 +0000 Subject: [PATCH 070/101] fix(link): preserve literal SSH include names on Bun 1.4.4 (#7629) * fix(test): promote Bun 1.4.4 with independent runner role verification * docs: align Docker Bun comments with unified role defaults * fix(link): preserve literal SSH include names on Bun 1.4.4 Quote literal parentheses and braces before passing OpenSSH Include patterns to Bun.Glob, preserving escapes and complete bracket classes. Add filesystem regressions for Bun 1.4.4 syntax and malformed bracket handling. Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> * test: use a draining TLS origin for early upload replies * test: confine draining origin to buffered HTTP/1 upload * test(server): isolate port reclaim fixture identity and clock * test: observe body before buffered early upload reply * test: close direct upload before fixture teardown --------- Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> Co-authored-by: JUN Co-authored-by: lidge-jun --- .../src/content/docs/guides/remote-link.md | 2 + src/link/ssh-config.ts | 38 +++++++++++++-- structure/remote-link.md | 2 +- tests/clients/link-ssh-config.test.ts | 46 ++++++++++++++++++- 4 files changed, 83 insertions(+), 5 deletions(-) diff --git a/docs-site/src/content/docs/guides/remote-link.md b/docs-site/src/content/docs/guides/remote-link.md index 7120ca099f0..5fd0d404030 100644 --- a/docs-site/src/content/docs/guides/remote-link.md +++ b/docs-site/src/content/docs/guides/remote-link.md @@ -27,6 +27,8 @@ The dashboard does not ask you to enter a token. It probes the host first, and i The Add Child sheet explains that the Child uses this Home's providers over SSH and lists the prerequisites above. Host aliases come from `~/.ssh/config` on the Home running OpenCodex, not necessarily the computer displaying the browser. If discovery succeeds with no hosts, add a `Host` entry like this, then choose **Rescan hosts**. You can also enter an existing alias manually; selecting or entering an alias enables **Test connection**. The sheet links to this guide. +Host discovery also follows top-level `Include` directives. Parentheses and braces in an included filename stay literal when combined with wildcards: for example, `Include conf.d/*(1).conf` finds `backup(1).conf`, and `Include conf.d/shard-{1..3}-*.conf` looks for filenames containing `{1..3}`. An `Include` inside a `Host` or `Match` block is conditional and is not used for discovery. + ```sshconfig Host devbox HostName devbox.example.com diff --git a/src/link/ssh-config.ts b/src/link/ssh-config.ts index 4e910588bb6..e82ac48926a 100644 --- a/src/link/ssh-config.ts +++ b/src/link/ssh-config.ts @@ -1,6 +1,6 @@ import { readFileSync } from "node:fs"; import { homedir } from "node:os"; -import { dirname, isAbsolute, join, resolve } from "node:path"; +import { dirname, isAbsolute, join, resolve, sep } from "node:path"; import { isSshAlias } from "./ssh-argv"; /** @@ -117,11 +117,43 @@ export interface LoadHostCandidatesOptions { glob?: (pattern: string) => string[]; } +/** OpenSSH Include uses glob(..., GLOB_TILDE): parentheses and braces are literal. */ +function normalizeIncludeGlob(pattern: string): string { + let normalized = ""; + for (let i = 0; i < pattern.length; i += 1) { + const char = pattern[i]!; + if (char === "\\" && i + 1 < pattern.length) { + normalized += char + pattern[++i]; + continue; + } + if (char === "[") { + let end = i + 1; + if (pattern[end] === "!" || pattern[end] === "^") end += 1; + if (pattern[end] === "]") end += 1; + for (; end < pattern.length; end += 1) { + if (pattern[end] === "\\") end += 1; + else if (pattern[end] === "]") break; + } + if (end < pattern.length) { + normalized += pattern.slice(i, end + 1); + i = end; + continue; + } + // Keep a malformed suffix intact: generated quoting must not close its class. + return normalized + pattern.slice(i); + } + // Character classes work on both Bun versions, without enabling 1.4.4's extglobs. + normalized += char === "(" ? "[(]" : char === "{" ? "[{]" : char; + } + return normalized; +} + function defaultGlob(pattern: string): string[] { if (!/[*?[]/.test(pattern)) return [pattern]; const base = dirname(pattern.slice(0, pattern.search(/[*?[]/) + 1)); - const relative = pattern.slice(base.length + 1); - return [...new Bun.Glob(relative).scanSync({ cwd: base, absolute: true, onlyFiles: true })].sort(); + // Only native Windows separators become slashes; POSIX backslash escapes stay intact. + const relative = pattern.slice(base.length + 1).split(sep).join("/"); + return [...new Bun.Glob(normalizeIncludeGlob(relative)).scanSync({ cwd: base, absolute: true, onlyFiles: true })].sort(); } /** diff --git a/structure/remote-link.md b/structure/remote-link.md index ca7404d3b16..2130303fc44 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -8,7 +8,7 @@ Every remote `ocx` call goes through `remoteOcxArgv`, a quoted POSIX `sh -c` scr `src/link/remote-platform.ts` recognises a Windows Child from one remote result: exit 125, a MINGW/MSYS/CYGWIN/Windows `uname -s`, or the cmd.exe or PowerShell "is not recognized" refusal of a POSIX command when the SSH shell has no POSIX sh. Any other non-transport failure (not 0, 125, 127 or 255) gets one locale-independent follow-up, the fixed `cmd /c echo %OS%` (`WINDOWS_OS_PROBE`) against the same strict host key, which prints `Windows_NT` under cmd and PowerShell in any display language. Enrollment maps all three to `remote_platform_unsupported` at probe or host confirmation, before host trust, key issuance or a tunnel, and the refusal names Hub mode (`ocx hub invite`, `ocx connect`). `ocx link cleanup` (`src/cli/link-cleanup.ts`) applies the same classifier to existing Home-initiated records over their pinned SSH trust and only reports: a Windows Child gets the recovery order disconnect on the Child, forced revoke on the Home, then Hub mode, because an ordinary revoke's remote `ocx disconnect` goes through the POSIX prelude that exits 125. Coverage: `tests/cli/cli-link-cleanup.test.ts`, `tests/server/link-enrollment.test.ts`. -`src/link/ssh-config.ts` lists host candidates from `~/.ssh/config`. Arguments are split with the rules of OpenSSH's `argv_split`. Pattern hosts, `Match` blocks and aliases that fail the alias check produce no candidates, and only top-level `Include` directives are followed, because an include inside a `Host` or `Match` block is conditional. A candidate is an offer, not trust. +`src/link/ssh-config.ts` lists host candidates from `~/.ssh/config`. Arguments are split with the rules of OpenSSH's `argv_split`. Pattern hosts, `Match` blocks and aliases that fail the alias check produce no candidates, and only top-level `Include` directives are followed, because an include inside a `Host` or `Match` block is conditional. Include discovery shields literal parentheses and braces from Bun's extended-glob and brace expansion, preserving existing escapes and character classes; wildcard matching, traversal options and lexical ordering remain with `Bun.Glob`. Coverage: `tests/clients/link-ssh-config.test.ts`. A candidate is an offer, not trust. `src/link/ssh-runner.ts` drains tunnel stdout and stderr with a 64 KiB cap per stream. After the SSH process exits, either reader that has not reached EOF gets 250 ms to consume remaining bytes, then is cancelled and its lock released without waiting for the underlying cancellation promise. Already consumed diagnostics are retained for host-key, auth and network classification, so an inherited pipe cannot indefinitely hold the supervisor's exit classification or pause retries and the outage clock. This grace applies to spawned tunnels; bounded one-shot commands retain their command timeout. diff --git a/tests/clients/link-ssh-config.test.ts b/tests/clients/link-ssh-config.test.ts index 50b4e42e0e3..7a2dfa914f3 100644 --- a/tests/clients/link-ssh-config.test.ts +++ b/tests/clients/link-ssh-config.test.ts @@ -1,7 +1,7 @@ import { afterEach, expect, test } from "bun:test"; import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { loadHostCandidates, parseHostCandidates, splitSshArgs } from "../../src/link/ssh-config"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -77,6 +77,50 @@ test("top-level Include follows relative globs but ignores conditional includes" ]); }); +for (const fixture of [ + { name: "star before parentheses", pattern: "conf.d/*(1).conf", files: ["conf.d/backup(1).conf", "conf.d/1.conf"], selected: [0] }, + { name: "question mark before parentheses", pattern: "conf.d/?(work).conf", files: ["conf.d/x(work).conf", "conf.d/work.conf"], selected: [0] }, + { name: "plus before parentheses", pattern: "conf.d/*+(work).conf", files: ["conf.d/a+(work).conf", "conf.d/awork.conf"], selected: [0] }, + { name: "at sign before parentheses", pattern: "conf.d/*@(work).conf", files: ["conf.d/a@(work).conf", "conf.d/awork.conf"], selected: [0] }, + { name: "exclamation mark before parentheses", pattern: "conf.d/*!(work).conf", files: ["conf.d/a!(work).conf", "conf.d/aother.conf"], selected: [0] }, + { name: "numeric brace range", pattern: "conf.d/shard-{1..3}-*.conf", files: ["conf.d/shard-{1..3}-a.conf", "conf.d/shard-1-a.conf"], selected: [0] }, + { name: "brace alternatives", pattern: "conf.d/{work,home}-*.conf", files: ["conf.d/{work,home}-a.conf", "conf.d/work-a.conf", "conf.d/home-a.conf"], selected: [0] }, + { name: "literal directory after a wildcard", pattern: "conf.*/@(work)/*.conf", files: ["conf.a/@(work)/a.conf", "conf.a/work/a.conf"], selected: [0] }, + { name: "ordinary question marks and classes", pattern: "conf.d/[ab]?.conf", files: ["conf.d/a1.conf", "conf.d/b2.conf", "conf.d/c3.conf", "conf.d/a12.conf"], selected: [0, 1] }, + { name: "parentheses and braces inside a class", pattern: "conf.d/[({]*.conf", files: ["conf.d/(a.conf", "conf.d/{b.conf", "conf.d/c.conf"], selected: [0, 1] }, + { name: "a leading closing bracket inside a class", pattern: "conf.d/[]({]*.conf", files: ["conf.d/(a.conf", "conf.d/]b.conf", "conf.d/{c.conf", "conf.d/d.conf"], selected: [0, 1, 2] }, + { name: "a negated class", pattern: "conf.d/[!({]*.conf", files: ["conf.d/(a.conf", "conf.d/{b.conf", "conf.d/c.conf"], selected: [2] }, + { name: "already quoted parentheses", pattern: "conf.d/*[(]1[)].conf", files: ["conf.d/backup(1).conf", "conf.d/1.conf"], selected: [0] }, + { name: "already quoted braces", pattern: "conf.d/*[{]1..3[}].conf", files: ["conf.d/shard{1..3}.conf", "conf.d/shard1.conf"], selected: [0] }, + { name: "an unclosed class cannot consume generated quoting", pattern: "conf.d/[[[ab*(c).conf", files: ["conf.d/bc).conf", "conf.d/c.conf"], selected: [] }, +]) { + test(`Include glob compatibility: ${fixture.name}`, () => { + const home = tempHome("glob"); + const sshDir = join(home, ".ssh"); + mkdirSync(sshDir, { recursive: true }); + writeFileSync(join(sshDir, "config"), `Include ${fixture.pattern}\n`); + for (const [index, file] of fixture.files.entries()) { + const path = join(sshDir, file); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `Host host${index}.example.test\n`); + } + expect(loadHostCandidates({ home }).map(candidate => candidate.alias)).toEqual( + fixture.selected.map(index => `host${index}.example.test`), + ); + }); +} + +test.skipIf(process.platform === "win32")("Include preserves an escaped parenthesis in a POSIX glob", () => { + const home = tempHome("escaped-glob"); + const sshDir = join(home, ".ssh"); + const includeDir = join(sshDir, "conf.d"); + mkdirSync(includeDir, { recursive: true }); + writeFileSync(join(sshDir, "config"), String.raw`Include conf.d/*\(1).conf`); + writeFileSync(join(includeDir, "backup(1).conf"), "Host literal.example.test\n"); + writeFileSync(join(includeDir, "1.conf"), "Host expanded.example.test\n"); + expect(loadHostCandidates({ home }).map(candidate => candidate.alias)).toEqual(["literal.example.test"]); +}); + test("Include recursion stops at the OpenSSH nesting limit", () => { const home = tempHome("recursive"); const sshDir = join(home, ".ssh"); From 2971af85630bc19586a13a67877f354810e01c5a Mon Sep 17 00:00:00 2001 From: Artur Safonov <31825780+artur-safonov@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:57:04 +0200 Subject: [PATCH 071/101] feat(combos): add opt-in cooldown recovery with configurable interval (#7723) * feat(combos): add opt-in interval-limited cooldown reprobe * fix(ci): shard combo test registration and refresh skill surface * fix(combos): scope cooldown recovery to eligible initial picks * fix(combos): preserve eligibility across recovery selection * fix(combos): admit encrypted recovery before cooldown reprobe --- docs-site/src/content/docs/guides/combos.md | 28 +++ scripts/test-layout/layout-combos.json | 3 + scripts/test-layout/layout.json | 2 +- .../references/01_surface_agents-routing.md | 4 +- src/cli/capabilities-agents-routing.ts | 4 +- src/cli/combo-result.ts | 7 + src/cli/combo.ts | 18 ++ src/combos/failover.ts | 7 +- src/combos/resolve.ts | 46 ++++- src/combos/types.ts | 24 +++ src/server/management/combo-routes.ts | 4 + src/server/responses/core-combo.ts | 33 +-- src/server/responses/request-prepare.ts | 5 +- src/types/config.ts | 6 + structure/fallback-policy.md | 2 + structure/providers-and-adapters.md | 2 +- structure/runtime.md | 2 +- tests/cli/cli-combo-parity.test.ts | 32 +++ .../combo-cooldown-reprobe.test.ts | 188 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + .../server/agent-task-recovery-combo.test.ts | 77 ++++++- tests/server/combo-metered-consent.test.ts | 2 +- .../server-combo-cooldown-recording.test.ts | 20 ++ 23 files changed, 493 insertions(+), 24 deletions(-) create mode 100644 scripts/test-layout/layout-combos.json create mode 100644 tests/codex-integration/combo-cooldown-reprobe.test.ts diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index 55faba32e8d..de670cbd17c 100644 --- a/docs-site/src/content/docs/guides/combos.md +++ b/docs-site/src/content/docs/guides/combos.md @@ -1029,3 +1029,31 @@ validation message. The error was terminal rather than target-specific. Fix invalid input, reduce an oversized context, handle a policy refusal, or correct the rejected request origin. Combos do not hop for those cases. + +## Retry all-cooled targets (opt-in) + +Combo cooldown recovery is opt-in and disabled by default. To allow one new +failover pass when every otherwise eligible target has a saved combo cooldown: + +```sh +ocx combo set review --cooldown-reprobe on --cooldown-reprobe-interval 300000 +ocx combo set review --cooldown-reprobe off +``` + +The corresponding per-combo configuration is: + +```json +{ "cooldownReprobe": { "enabled": true, "intervalMs": 300000 } } +``` + +The default interval is five minutes; accepted values are integer milliseconds +from 1000 to 86400000. Setting the interval alone does not enable recovery. +Recovery is demand-driven: the first eligible request can reserve a pass immediately, +and later passes are separated by at least the configured interval. There is no timer +or background traffic. Each target can be attempted once in the request's existing +failover budget; failures record fresh cooldowns. Account quota checks, disabled +providers, request compatibility, metered-spend permissions and cancellation remain +enforced. Explicit provider Retry-After values may therefore be retried early only +when the operator enables this option. Preview/list operations never clear cooldowns. +The interval is tracked per combo in memory and resets on proxy restart or combo +selection-state reset. Existing cooldown-wait behavior is unchanged when disabled. diff --git a/scripts/test-layout/layout-combos.json b/scripts/test-layout/layout-combos.json new file mode 100644 index 00000000000..9faa45d117b --- /dev/null +++ b/scripts/test-layout/layout-combos.json @@ -0,0 +1,3 @@ +{ + "combo-cooldown-reprobe.test.ts": "codex-integration" +} diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index f3a4bcc5fd8..bc218fcb7b0 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1,7 +1,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"], + "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"], "explicit": { "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", "service-proxy-env.test.ts": "service", "openai-chat-standalone-bare-block.test.ts": "adapters/openai", "owner-registry-isolation.test.ts": "config", diff --git a/skills/ocx/references/01_surface_agents-routing.md b/skills/ocx/references/01_surface_agents-routing.md index 48c95c7c68f..27f730badc9 100644 --- a/skills/ocx/references/01_surface_agents-routing.md +++ b/skills/ocx/references/01_surface_agents-routing.md @@ -958,7 +958,7 @@ State-changing: no. JSON mode: `payload`. ### `ocx combo set` -Usage: `ocx combo set [--targets | --targets-file ] [--strategy ] [--sticky <1-100|->] [--effort ] [--effort-mode ] [--alias ] [--native-alias [on|off]] [--allow-metered-targets ] [--display-name ] [--description ] [--decision-provider | --decision-model ] [--decision-timeout ] [--decision-quota-signals ] [--decision-quota-tiers ] [--session-pin ] [--rename-from ] [--image-input ] [--reasoning-effort-mode ] [--json]` +Usage: `ocx combo set [--targets | --targets-file ] [--strategy ] [--sticky <1-100|->] [--cooldown-reprobe ] [--cooldown-reprobe-interval ] [--effort ] [--effort-mode ] [--alias ] [--native-alias [on|off]] [--allow-metered-targets ] [--display-name ] [--description ] [--decision-provider | --decision-model ] [--decision-timeout ] [--decision-quota-signals ] [--decision-quota-tiers ] [--session-pin ] [--rename-from ] [--image-input ] [--reasoning-effort-mode ] [--json]` Create, update or rename a combo. @@ -975,6 +975,8 @@ State-changing: yes. JSON mode: `payload`. | `--targets` | string | Ordered provider/model[:weight] list; required for creation. | | `--strategy` | string | Routing strategy. | | `--sticky` | string | Round-robin limit 1-100; - resets. | +| `--cooldown-reprobe` | string | Opt into one recovery pass when all eligible targets cool; off by default. | +| `--cooldown-reprobe-interval` | string | Minimum interval between recovery passes in ms (1000..86400000); default 300000. | | `--effort` | string | Default effort; - clears. | | `--effort-mode` | string | fallback or force; - resets to fallback. | | `--alias` | string | Alias; - clears. | diff --git a/src/cli/capabilities-agents-routing.ts b/src/cli/capabilities-agents-routing.ts index ff5adad3fa4..a49ba0a3470 100644 --- a/src/cli/capabilities-agents-routing.ts +++ b/src/cli/capabilities-agents-routing.ts @@ -385,10 +385,10 @@ export const AGENT_ROUTING_CAPABILITIES: readonly Capability[] = [ }, { command: ["combo","set"], - usage: "ocx combo set [--targets | --targets-file ] [--strategy ] [--sticky <1-100|->] [--effort ] [--effort-mode ] [--alias ] [--native-alias [on|off]] [--allow-metered-targets ] [--display-name ] [--description ] [--decision-provider | --decision-model ] [--decision-timeout ] [--decision-quota-signals ] [--decision-quota-tiers ] [--session-pin ] [--rename-from ] [--image-input ] [--reasoning-effort-mode ] [--json]", + usage: "ocx combo set [--targets | --targets-file ] [--strategy ] [--sticky <1-100|->] [--cooldown-reprobe ] [--cooldown-reprobe-interval ] [--effort ] [--effort-mode ] [--alias ] [--native-alias [on|off]] [--allow-metered-targets ] [--display-name ] [--description ] [--decision-provider | --decision-model ] [--decision-timeout ] [--decision-quota-signals ] [--decision-quota-tiers ] [--session-pin ] [--rename-from ] [--image-input ] [--reasoning-effort-mode ] [--json]", summary: "Create, update or rename a combo.", routes: [{"method":"GET","path":"/api/combos"},{"method":"PUT","path":"/api/combos"}], - flags: [{"name":"--allow-metered-targets","value":"string","summary":"on grants operator consent to metered targets only on a loopback-only proxy; off clears it."},{"name":"--targets","value":"string","summary":"Ordered provider/model[:weight] list; required for creation."},{"name":"--strategy","value":"string","summary":"Routing strategy."},{"name":"--sticky","value":"string","summary":"Round-robin limit 1-100; - resets."},{"name":"--effort","value":"string","summary":"Default effort; - clears."},{"name":"--effort-mode","value":"string","summary":"fallback or force; - resets to fallback."},{"name":"--alias","value":"string","summary":"Alias; - clears."},{"name":"--display-name","value":"string","summary":"Label; - clears."},{"name":"--description","value":"string","summary":"Catalog row description; - clears."},{"name":"--decision-provider","value":"string","summary":"JEV provider; - clears."},{"name":"--decision-model","value":"string","summary":"JEV route; - clears; exclusive with provider."},{"name":"--decision-timeout","value":"string","summary":"JEV deadline 1000-120000 ms; - clears."},{"name":"--decision-quota-signals","value":"string","summary":"JEV advisory quota on/off; - clears."},{"name":"--decision-quota-tiers","value":"string","summary":"Partial JSON thresholds; - clears."},{"name":"--session-pin","value":"string","summary":"JEV session pin idle window 1m-24h (90s, 20m, 1h); - clears."},{"name":"--rename-from","value":"string","summary":"Existing combo ID to rename."},{"name":"--json","value":"boolean","summary":"Emit structured JSON."},{"name":"--native-alias","value":"string","summary":"Bare legacy flag means on; explicit on/off preserves presence, including false."},{"name":"--targets-file","value":"string","summary":"Bounded ordered target JSON array, exclusive with --targets; preserves effort/profile/last-resort metadata."},{"name":"--image-input","value":"string","summary":"auto or disabled; explicit auto clears the previous disabled choice."},{"name":"--reasoning-effort-mode","value":"string","summary":"strict or adaptive, distinct from --effort-mode fallback/force."}], + flags: [{"name":"--allow-metered-targets","value":"string","summary":"on grants operator consent to metered targets only on a loopback-only proxy; off clears it."},{"name":"--targets","value":"string","summary":"Ordered provider/model[:weight] list; required for creation."},{"name":"--strategy","value":"string","summary":"Routing strategy."},{"name":"--sticky","value":"string","summary":"Round-robin limit 1-100; - resets."},{"name":"--cooldown-reprobe","value":"string","summary":"Opt into one recovery pass when all eligible targets cool; off by default."},{"name":"--cooldown-reprobe-interval","value":"string","summary":"Minimum interval between recovery passes in ms (1000..86400000); default 300000."},{"name":"--effort","value":"string","summary":"Default effort; - clears."},{"name":"--effort-mode","value":"string","summary":"fallback or force; - resets to fallback."},{"name":"--alias","value":"string","summary":"Alias; - clears."},{"name":"--display-name","value":"string","summary":"Label; - clears."},{"name":"--description","value":"string","summary":"Catalog row description; - clears."},{"name":"--decision-provider","value":"string","summary":"JEV provider; - clears."},{"name":"--decision-model","value":"string","summary":"JEV route; - clears; exclusive with provider."},{"name":"--decision-timeout","value":"string","summary":"JEV deadline 1000-120000 ms; - clears."},{"name":"--decision-quota-signals","value":"string","summary":"JEV advisory quota on/off; - clears."},{"name":"--decision-quota-tiers","value":"string","summary":"Partial JSON thresholds; - clears."},{"name":"--session-pin","value":"string","summary":"JEV session pin idle window 1m-24h (90s, 20m, 1h); - clears."},{"name":"--rename-from","value":"string","summary":"Existing combo ID to rename."},{"name":"--json","value":"boolean","summary":"Emit structured JSON."},{"name":"--native-alias","value":"string","summary":"Bare legacy flag means on; explicit on/off preserves presence, including false."},{"name":"--targets-file","value":"string","summary":"Bounded ordered target JSON array, exclusive with --targets; preserves effort/profile/last-resort metadata."},{"name":"--image-input","value":"string","summary":"auto or disabled; explicit auto clears the previous disabled choice."},{"name":"--reasoning-effort-mode","value":"string","summary":"strict or adaptive, distinct from --effort-mode fallback/force."}], mutates: true, json: "payload", details: ["create and update are aliases of set. Existing fields are read before writing; --targets replaces targets. Without --strategy an existing combo keeps its stored strategy and JEV policy; a new combo defaults to failover.","force overrides valid client effort and may increase cost and latency.","Only the implemented target grammar is supported; full GUI target metadata and routing-profile writes are separate tasks.","New file/flag conflicts refuse before transport. Partial edits preserve target metadata; explicit false/default values override server carry. Turning a native alias off may also require clearing its incompatible alias.","Read/merge/write uses one target and remains an upsert without revision CAS. Saved-but-incomplete catalog outcomes return nonzero with their receipt."], diff --git a/src/cli/combo-result.ts b/src/cli/combo-result.ts index b98b9560547..e077cd041d1 100644 --- a/src/cli/combo-result.ts +++ b/src/cli/combo-result.ts @@ -40,6 +40,13 @@ function publicCombo(value: unknown): Record { if (!tiers) invalid(); result[key] = tiers; continue; + } else if (key === "cooldownReprobe") { + if (!record(child) || typeof child.enabled !== "boolean" + || Object.keys(child).some(field => field !== "enabled" && field !== "intervalMs")) invalid(); + if (child.intervalMs !== undefined && (typeof child.intervalMs !== "number" + || !Number.isSafeInteger(child.intervalMs) || child.intervalMs < 1000 || child.intervalMs > 86_400_000)) invalid(); + result[key] = { enabled: child.enabled, ...(child.intervalMs !== undefined ? { intervalMs: child.intervalMs } : {}) }; + continue; } else if (key === "sessionPin") { const pin = normalizeJevSessionPin(child); if (!pin) invalid(); diff --git a/src/cli/combo.ts b/src/cli/combo.ts index 84812e86d33..a97174942b7 100644 --- a/src/cli/combo.ts +++ b/src/cli/combo.ts @@ -24,6 +24,7 @@ const USAGE = `Usage: ocx combo show [--json] ocx combo set [--targets | --targets-file ] [--strategy ] [--sticky <1-100|->] + [--cooldown-reprobe ] [--cooldown-reprobe-interval ] [--effort ] [--effort-mode ] (force overrides valid client effort and can increase cost/latency) [--alias ] [--native-alias [on|off]] [--allow-metered-targets ] [--display-name ] @@ -114,6 +115,16 @@ async function set(argv: string[], deps: RuntimeApiDeps): Promise { throw new CliUsageError("--sticky must be an integer from 1 to 100, or -", USAGE); } if (stickyLimit !== undefined && stickyLimit > 100) throw new CliUsageError("--sticky must be <= 100", USAGE); + const reprobe = takeOption(args, "--cooldown-reprobe"); + const reprobeInterval = takeOption(args, "--cooldown-reprobe-interval"); + if (reprobe !== undefined && reprobe !== "on" && reprobe !== "off") { + throw new CliUsageError("--cooldown-reprobe must be on or off", USAGE); + } + const reprobeIntervalMs = reprobeInterval === undefined ? undefined : Number(reprobeInterval); + if (reprobeIntervalMs !== undefined && (!Number.isSafeInteger(reprobeIntervalMs) + || reprobeIntervalMs < 1000 || reprobeIntervalMs > 86_400_000)) { + throw new CliUsageError("--cooldown-reprobe-interval must be an integer from 1000 to 86400000 ms", USAGE); + } const effort = takeOption(args, "--effort"); const effortMode = takeOption(args, "--effort-mode"); if (effortMode !== undefined && effortMode !== "fallback" && effortMode !== "force" && effortMode !== "-") { @@ -180,6 +191,13 @@ async function set(argv: string[], deps: RuntimeApiDeps): Promise { const combo: Record = existing ? Object.fromEntries(Object.entries(existing).filter(([, value]) => value !== null)) : { strategy, stickyLimit: stickyLimit ?? 1 }; + if (reprobe !== undefined || reprobeIntervalMs !== undefined) { + const previous = (combo.cooldownReprobe ?? {}) as { enabled?: boolean; intervalMs?: number }; + combo.cooldownReprobe = { ...previous, + enabled: reprobe === undefined ? previous.enabled === true : reprobe === "on", + ...(reprobeIntervalMs !== undefined ? { intervalMs: reprobeIntervalMs } : {}), + }; + } delete combo.id; delete combo.model; if (replacingTargets) combo.targets = additional.targets ?? parsedTargets; diff --git a/src/combos/failover.ts b/src/combos/failover.ts index bf590e6ee0b..1041f7ef037 100644 --- a/src/combos/failover.ts +++ b/src/combos/failover.ts @@ -313,7 +313,8 @@ export function sweepExpiredComboTargetCooldowns(now = Date.now()): number { return removed; } -export function clearComboTargetCooldowns(comboId?: string): void { +/** Clear all cooldowns, one combo, or only the supplied target keys within that combo. */ +export function clearComboTargetCooldowns(comboId?: string, targetKeys?: Iterable): void { if (comboId === undefined) { targetCooldowns.clear(); liveComboTargets.clear(); @@ -321,6 +322,10 @@ export function clearComboTargetCooldowns(comboId?: string): void { return; } const prefix = `${comboId}\0`; + if (targetKeys !== undefined) { + for (const key of targetKeys) targetCooldowns.delete(`${prefix}${key}`); + return; + } for (const key of targetCooldowns.keys()) { if (key.startsWith(prefix)) targetCooldowns.delete(key); } diff --git a/src/combos/resolve.ts b/src/combos/resolve.ts index bf7bfe125f6..60d07e6ba61 100644 --- a/src/combos/resolve.ts +++ b/src/combos/resolve.ts @@ -3,6 +3,7 @@ import { getCachedProviderRoutingQuota } from "../providers/quota-routing-cache" import type { ProviderQuota, ProviderQuotaWindow } from "../providers/quota-types"; import { sleepWithAbort } from "../lib/upstream-retry"; import { + clearComboTargetCooldowns, coolComboTarget, earliestComboCooldown, isComboTargetInCooldown, @@ -26,6 +27,7 @@ export interface ComboPick { } interface SelectionState { + lastCooldownReprobeAt?: number; activeKey?: string; successes: number; currentWeights: Map; @@ -286,12 +288,42 @@ function resetWindowIndex( return selected; } +/** Reserve one recovery pass before mutating cooldowns; preview and in-flight picks never call this. */ +function reopenAllCooledTargets( + config: OcxConfig, comboId: string, now: number, + eligible?: (target: NormalizedComboTarget) => boolean, +): void { + const combo = getCombo(config, comboId); + if (!combo?.cooldownReprobe?.enabled) return; + const state = selectionState.get(comboId); + if (state?.lastCooldownReprobeAt !== undefined + && now - state.lastCooldownReprobeAt < combo.cooldownReprobe.intervalMs) return; + const reprobeEligible = (target: NormalizedComboTarget): boolean => { + if (!targetProviderIsUsable(config, target, now)) return false; + try { return eligible?.(target) ?? true; } catch { return false; } + }; + // Ordinary selection skips cooled targets without evaluating their request predicate. + // Preserve that behavior when a sibling is already available. + if (combo.targets.some(target => !isComboTargetInCooldown(comboId, target, now) + && reprobeEligible(target))) return; + const usable = combo.targets.filter(reprobeEligible); + if (usable.length === 0 || !usable.every(target => isComboTargetInCooldown(comboId, target, now))) return; + // No await between reservation and reset: concurrent requests cannot reserve another pass. + const next = state ?? { successes: 0, currentWeights: new Map(), successfulUses: new Map() }; + next.lastCooldownReprobeAt = now; + selectionState.set(comboId, next); + clearComboTargetCooldowns(comboId, usable.map(targetKey)); + console.warn(`[combo] ${comboId}: reopening all-cooled targets; minimum reprobe interval ${combo.cooldownReprobe.intervalMs}ms`); +} + export function pickComboTarget( config: OcxConfig, comboId: string, options: { exclude?: Iterable; eligible?: (target: NormalizedComboTarget) => boolean; + /** Disable recovery for picks without request context or picks already in flight. */ + allowCooldownReprobe?: boolean; now?: number; /** Inspect a round-robin choice without mutating its sticky/weight state. */ preview?: boolean; @@ -302,6 +334,9 @@ export function pickComboTarget( if (!combo) throw new UnknownComboError(comboId); const excluded = new Set(options.exclude ?? []); const now = options.now ?? Date.now(); + if (options.allowCooldownReprobe !== false && !options.preview && excluded.size === 0) { + reopenAllCooledTargets(config, comboId, now, options.eligible); + } const eligible = (target: NormalizedComboTarget): boolean => targetProviderIsUsable(config, target, now) && !isComboTargetInCooldown(comboId, target, now) @@ -498,6 +533,8 @@ export async function pickComboTargetWithWait( eligible?: (target: NormalizedComboTarget) => boolean; waitForCooldownMs: number; abortSignal?: AbortSignal; + /** Disable recovery when retrying a target after its normal cooldown. */ + allowCooldownReprobe?: boolean; now?: number; sleep?: (ms: number, signal?: AbortSignal) => Promise; }, @@ -505,6 +542,9 @@ export async function pickComboTargetWithWait( const now = options.now ?? Date.now(); const excluded = new Set(options.exclude ?? []); const customEligible = options.eligible; + if (options.allowCooldownReprobe !== false && excluded.size === 0 && !options.abortSignal?.aborted) { + reopenAllCooledTargets(config, comboId, now, customEligible); + } const eligibleAt = (target: NormalizedComboTarget, at: number): boolean => !isComboTargetInCooldown(comboId, target, at) && (customEligible?.(target) ?? true); @@ -529,6 +569,7 @@ export async function pickComboTargetWithWait( const normalOnly = (target: NormalizedComboTarget): boolean => !target.lastResort && eligible(target); const normalPick = pickComboTarget(config, comboId, { + allowCooldownReprobe: false, exclude: excluded, eligible: normalOnly, now, @@ -559,6 +600,7 @@ export async function pickComboTargetWithWait( // The combo can be deleted or renamed while this request sleeps. if (!getCombo(config, comboId)) return null; const waited = pickComboTarget(config, comboId, { + allowCooldownReprobe: false, exclude: excluded, now: now + normalDelay, eligible: target => !target.lastResort @@ -576,6 +618,7 @@ export async function pickComboTargetWithWait( const remainingWaitMs = options.waitForCooldownMs - spentWaitMs; const pick = pickComboTarget(config, comboId, { + allowCooldownReprobe: false, exclude: excluded, eligible: target => eligibleAt(target, clock), now: clock, @@ -610,6 +653,7 @@ export async function pickComboTargetWithWait( if (!getCombo(config, comboId)) return null; return pickComboTarget(config, comboId, { exclude: excluded, + allowCooldownReprobe: false, now: clock + delay, eligible: targetCandidate => eligibleAt(targetCandidate, clock + delay), }); @@ -659,7 +703,7 @@ export function tryPickComboModel(config: OcxConfig, modelId: string, preview = const comboId = resolveComboId(config, modelId); if (!comboId) return null; if (!getCombo(config, comboId)) throw new UnknownComboError(comboId); - const picked = pickComboTarget(config, comboId, { preview }); + const picked = pickComboTarget(config, comboId, { preview, allowCooldownReprobe: false }); if (!picked) throw new NoAvailableComboTargetsError(comboId, comboExclusionReasons(config, comboId)); return picked; } diff --git a/src/combos/types.ts b/src/combos/types.ts index f3256fe43c8..7f35f915a18 100644 --- a/src/combos/types.ts +++ b/src/combos/types.ts @@ -17,6 +17,7 @@ import { } from "./jev-decision-contract"; export const COMBO_DEFAULT_WAIT_FOR_COOLDOWN_MS = 0; +export const COMBO_DEFAULT_COOLDOWN_REPROBE_INTERVAL_MS = 300_000; export const JEV_MAX_CANDIDATE_FIELD_CHARS = 512; export { JEV_DECISION_TIMEOUT_DEFAULT_MS, @@ -60,6 +61,7 @@ export interface NormalizedComboConfig { stickyLimit: number; cooldownMs?: number; waitForCooldownMs: number; + cooldownReprobe?: { enabled: true; intervalMs: number }; /** `before-last-resort` defers lastResort targets while a normal one can be waited out (#5691). */ cooldownWaitPolicy: OcxComboCooldownWaitPolicy | null; defaultEffort: OcxComboDefaultEffort | null; @@ -222,6 +224,24 @@ export function comboConfigIssues( || body.waitForCooldownMs > 600_000)) { issues.push({ path: ["waitForCooldownMs"], message: "waitForCooldownMs must be an integer from 0 to 600000" }); } + if (body.cooldownReprobe !== undefined) { + const value = body.cooldownReprobe; + if (!value || typeof value !== "object" || Array.isArray(value)) { + issues.push({ path: ["cooldownReprobe"], message: "cooldownReprobe must be an object" }); + } else { + const reprobe = value as Record; + if (typeof reprobe.enabled !== "boolean") { + issues.push({ path: ["cooldownReprobe", "enabled"], message: "cooldownReprobe.enabled must be a boolean" }); + } + if (reprobe.intervalMs !== undefined && (typeof reprobe.intervalMs !== "number" + || !Number.isSafeInteger(reprobe.intervalMs) || reprobe.intervalMs < 1000 || reprobe.intervalMs > 86_400_000)) { + issues.push({ path: ["cooldownReprobe", "intervalMs"], message: "cooldownReprobe.intervalMs must be an integer from 1000 to 86400000" }); + } + if (Object.keys(reprobe).some(key => key !== "enabled" && key !== "intervalMs")) { + issues.push({ path: ["cooldownReprobe"], message: "cooldownReprobe supports only enabled and intervalMs" }); + } + } + } if (body.cooldownWaitPolicy !== undefined && body.cooldownWaitPolicy !== null && body.cooldownWaitPolicy !== "before-last-resort") { issues.push({ @@ -535,6 +555,10 @@ export function normalizeComboConfig(raw: OcxComboConfig): NormalizedComboConfig ...(raw.allowMeteredTargets === true ? { allowMeteredTargets: true } : {}), stickyLimit: raw.stickyLimit ?? 1, cooldownMs: raw.cooldownMs, + ...(raw.cooldownReprobe?.enabled === true ? { cooldownReprobe: { + enabled: true as const, + intervalMs: raw.cooldownReprobe.intervalMs ?? COMBO_DEFAULT_COOLDOWN_REPROBE_INTERVAL_MS, + } } : {}), waitForCooldownMs: raw.waitForCooldownMs ?? COMBO_DEFAULT_WAIT_FOR_COOLDOWN_MS, cooldownWaitPolicy: raw.cooldownWaitPolicy === "before-last-resort" ? "before-last-resort" : null, defaultEffort, diff --git a/src/server/management/combo-routes.ts b/src/server/management/combo-routes.ts index bcb1de6da21..9336019b4e8 100644 --- a/src/server/management/combo-routes.ts +++ b/src/server/management/combo-routes.ts @@ -222,6 +222,10 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise = body.combo; const effectiveCombo = { ...requestedCombo, + // Clients that do not expose this opt-in must preserve it on unrelated edits. + ...(!Object.hasOwn(requestedCombo, "cooldownReprobe") && previous?.cooldownReprobe !== undefined + ? { cooldownReprobe: previous.cooldownReprobe } + : {}), ...(!Object.hasOwn(requestedCombo, "decisionQuotaSignals") && requestedCombo.strategy === "jev" && previous?.decisionQuotaSignals !== undefined ? { decisionQuotaSignals: previous.decisionQuotaSignals } : {}), ...(!Object.hasOwn(requestedCombo, "decisionQuotaTiers") && requestedCombo.strategy === "jev" && previous?.decisionQuotaTiers !== undefined ? { decisionQuotaTiers: previous.decisionQuotaTiers } : {}), // The dashboard does not edit the session pin (#7525): an omitted field keeps it on a JEV save. diff --git a/src/server/responses/core-combo.ts b/src/server/responses/core-combo.ts index 8c801131992..64e296ade5f 100644 --- a/src/server/responses/core-combo.ts +++ b/src/server/responses/core-combo.ts @@ -509,12 +509,14 @@ export async function executeComboResponses( const payloadEligible = (target: (typeof combo.targets)[number]): boolean => comboPayloadReadable || !unreadableEncryptedAgentTask || canDecryptUnreadableAgentTask(target); const meteredAllowed = admissionMayUseMeteredComboTargets(config, options.admission, combo); - const targetEligible = (target: (typeof combo.targets)[number]): boolean => + // 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) - && payloadEligible(target) && reasoningReplayEligible(target) && (protocolLanes?.pickable(target) ?? true); + const targetEligible = (target: (typeof combo.targets)[number]): boolean => + payloadEligible(target) && recoveryEligible(target); const onlyReplayIncompatibleTargetsRemain = (excluded: Iterable = []): boolean => { const excludedKeys = new Set(excluded); const remaining = combo.targets.filter(target => { @@ -528,16 +530,13 @@ export async function executeComboResponses( let encryptedTaskRecoveryAttempted = false; let recoveryFailureReason: AgentTaskRecoveryFailureReason | undefined; let storedPool401ReplayDispatched = false; + const recovery = agentTaskRecoveryConfig(config); + const encryptedTaskRecoveryAdmitted = (options.inboundWire ?? "responses") === "responses" + && isThreadSpawnRequest(req.headers) && !!recovery && !options.comboAttempt; const recoverUnreadableEncryptedTask = async (): Promise => { if (encryptedTaskRecoveryAttempted) return false; encryptedTaskRecoveryAttempted = true; - const recovery = agentTaskRecoveryConfig(config); - if ( - (options.inboundWire ?? "responses") !== "responses" - || !isThreadSpawnRequest(req.headers) - || !recovery - || options.comboAttempt - ) { + if (!encryptedTaskRecoveryAdmitted || !recovery) { discardEncryptedAgentTaskRecovery( req, (body as { input?: unknown } | undefined)?.input, @@ -582,6 +581,7 @@ export async function executeComboResponses( const pickWithWait = (pickOptions: { exclude?: Iterable; eligible?: (target: NonNullable["targets"][number]) => boolean; + allowCooldownReprobe?: boolean; now?: number; }) => pickComboTargetWithWait(config, comboId, { ...pickOptions, @@ -613,7 +613,11 @@ export async function executeComboResponses( : jevSessionPinScope({ combo, comboId, admission: options.admission, lane: sessionLaneIdFromRequest(req.headers) }); if (unreadableEncryptedAgentTask && !pick) { - pick = await pickWithWait({ now: initialNow }); + pick = await pickWithWait({ + eligible: recoveryEligible, + allowCooldownReprobe: encryptedTaskRecoveryAdmitted, + now: initialNow, + }); if (!pick) { discardEncryptedAgentTaskRecovery( req, @@ -624,7 +628,7 @@ export async function executeComboResponses( return options.abortSignal?.aborted ? clientCancelledResponse() : comboUnavailable(comboId, undefined, - comboExclusionReasons(config, comboId, target => meteredAllowed || !target.metered)); + comboExclusionReasons(config, comboId, recoveryEligible)); } if (!(await recoverUnreadableEncryptedTask())) { return options.abortSignal?.aborted @@ -1274,6 +1278,7 @@ export async function executeComboResponses( const recoveredTarget = await pickWithWait({ exclude: pick.attempted, eligible: target => { + if (!recoveryEligible(target)) return false; try { const route = routeConcreteModel(config, `${target.provider}/${target.model}`); return route.codexAccountMode === undefined @@ -1362,10 +1367,11 @@ export async function executeComboResponses( const failedTargetCooled = failureCooldownScope !== "none" && failedTargetCooldownRecorded && isComboTargetInCooldown(comboId, pick.target, failureNow); - // Same target selector as the exclusionary pick below, minus `exclude`: the only - // difference is deliberate and is the whole point of the single-target retry. + // Drop exclusions for the single-target retry, but wait for normal cooldown + // expiry: an in-flight request cannot reserve a fresh recovery pass. const retryAfterCooldown = () => pickWithWait({ + allowCooldownReprobe: false, eligible: targetEligible, now: failureNow, }); @@ -1407,6 +1413,7 @@ export async function executeComboResponses( if (unreadableEncryptedAgentTask && !comboPayloadReadable) { const recoveredTarget = await pickWithWait({ exclude: attemptedTargets, + eligible: recoveryEligible, now: failureNow, }); if (recoveredTarget && await recoverUnreadableEncryptedTask()) { diff --git a/src/server/responses/request-prepare.ts b/src/server/responses/request-prepare.ts index dbd8496f39d..8a56b7abdd6 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -665,7 +665,10 @@ export async function prepareResponsesRequest( if (candidate.combo?.target.metered && !admissionMayUseMeteredComboTargets( config, options.admission, config.combos?.[candidate.combo.comboId], )) { - const pick = pickComboTarget(config, candidate.combo.comboId, { eligible: target => !target.metered }); + const pick = pickComboTarget(config, candidate.combo.comboId, { + eligible: target => !target.metered, + allowCooldownReprobe: false, + }); if (!pick) { throw new NoAvailableComboTargetsError(candidate.combo.comboId, comboExclusionReasons(config, candidate.combo.comboId, target => !target.metered)); diff --git a/src/types/config.ts b/src/types/config.ts index fedcf82a440..276e876e81e 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -1435,6 +1435,12 @@ export interface OcxComboConfig { cooldownMs?: number; /** Maximum wait for an eligible target cooldown to expire before failing closed. Default 0; range 0..600000, per selection attempt. */ waitForCooldownMs?: number; + /** Opt-in retry pass when all otherwise eligible targets are cooling. Disabled by default. */ + cooldownReprobe?: { + enabled: boolean; + /** Minimum interval between recovery passes, in milliseconds. Default 300000; range 1000..86400000. */ + intervalMs?: number; + }; /** * `before-last-resort` defers targets marked `lastResort` while a normal * target is merely cooling and that cooldown can be waited out inside diff --git a/structure/fallback-policy.md b/structure/fallback-policy.md index fc7ec6f9c38..ac3c1f0ff10 100644 --- a/structure/fallback-policy.md +++ b/structure/fallback-policy.md @@ -129,3 +129,5 @@ privacy), `tests/responses/responses-combo-fallback-off.test.ts`, `tests/routing `tests/routing/subagent-model-fallback-off.test.ts`, `tests/codex-integration/main-account-hard-lock-fallback-off.test.ts`, `tests/server/fallback-routes.test.ts`, `tests/clients/client-home-unreachable.test.ts`, `tests/cli/cli-fallback.test.ts` and `gui/tests/fallback-panel.test.tsx`. + +The opt-in [all-cooled Combo recovery](runtime.md#scoped-provider-quota-for-combo-selection) can reopen saved combo cooldowns in `src/combos/resolve.ts`; only the request-eligible target keys are cleared, with throwing predicates treated as ineligible during recovery preparation and available siblings preferred without probing cooled targets. Route-level picks and in-flight retry-after-cooldown selections disable reprobes. Recovery does not authorize a fallback hop or bypass the configured fallback policy. diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 32a4a3789c1..ad088a20e0b 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -589,7 +589,7 @@ Account-row projection is loaded with `import()` after each account probe in `sr remains authoritative. No raw body is retained in that field, serialized into client errors, or logged. Presence of both `detail` and `error` across the original root and its own non-array `response` record is ambiguous, regardless of values; null counts and deeper quoted objects do not. This is checked before selecting an error carrier. For complete bounded HTTP errors, the text wrapper selects root `detail`/`error` when present; otherwise it selects the own `response` record for the same exact `detail` or `error.message` match. It never searches another carrier after a nonmatch or descends further. Policy-fallback and direct callers share this wrapper; SSE message parsing disables nested HTTP selection. The same bounded HTTP parser retains allowlisted dedicated `code` fields from root and response before display truncation; diagnostic `type` is never promoted, including when code is absent or malformed. Policy/direct classification inspects full text only without authoritative carrier metadata; SSE never borrows hard codes from quoted message JSON. -Committed output, cancellation, hard policy refusals and resend limits remain stronger than model fallback. +Committed output, cancellation, hard policy refusals and resend limits remain stronger than model fallback. Opt-in all-cooled target recovery in `src/combos/resolve.ts` follows [the Combo cooldown contract](runtime.md#scoped-provider-quota-for-combo-selection) without widening account/provider admission. It clears only request-eligible target cooldowns; route-level selection (including an unmetered reroute) and in-flight cooldown retries cannot initiate a reprobe. Encrypted-task recovery preserves request-specific JEV, reasoning-replay and protocol eligibility while relaxing payload readability. Token Plan dated-model policy (2026-10-20): both curated presets include exact `deepseek-v4.1-flash-1017` metadata without changing live discovery or Responses pins. diff --git a/structure/runtime.md b/structure/runtime.md index 15baf9348fd..bc6383fb32c 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -504,7 +504,7 @@ This is also why the classifier cannot duplicate visible output. Native byte str Regression coverage: `tests/responses/responses-forward-prompt-envelope.test.ts`, `tests/routing/router-combo-failover-classification.test.ts`, `tests/routing/routing-policy-fallback.test.ts`, `tests/helpers/combo-context-overflow-cases.ts`, and `tests/server/server-combo-failover-e2e.test.ts`. Every combo, routing-profile, subagent and hard-lock hop to another model or provider first passes the [fallback policy](fallback-policy.md#sites). -`src/combos/failover.ts` uses a 10-minute fallback for a spent account usage window (codes `usage_limit_exceeded`, `usage_limit_reached`, `1308`, or `usage limit reached` / `usage limit has been reached` / `token-plan quota has been exhausted` prose, including HTTP 502) and for provider-scoped credential or billing failure codes such as `invalid_api_key` and `insufficient_quota`. This duration does not change failure classification or cooldown scope; upstream retry/reset signals and configured durations retain precedence. It caps explicit upstream `Retry-After` target cooldowns at 24 hours while reset-derived, configured, and fallback cooldowns remain capped at 10 minutes. `src/combos/resolve.ts` omits a target cooldown for a 429 only when the failed child identifies one account in an Anthropic OAuth or canonical Codex pool and that account has a live pool cooldown, or identifies one account in a generic OAuth pool that still has an eligible sibling. `src/server/responses/core-combo.ts` checks the pool's health record before passing that attribution; for a generic OAuth pool it first hands the account (matched through its `accountLogLabel`) to `rotateGenericOAuthAccountOn429`, which cools it under the pool's own Retry-After policy even when the per-request account budget ended rotation before that account was recorded, and attributes the failure only when the rotator names a sibling. Unknown accounts, ambiguous labels, a lone or fully cooled generic OAuth pool, API-key providers, and other failures keep their ordinary target cooldown. An API-key pool needs no attribution: its 429 rotation walks every key inside the attempt, so a 429 reaches the combo only once every key is cooled. Regression coverage: `tests/oauth/generic-oauth-combo-account-cooldown.test.ts`. A 402 is the provider-scoped twin for an Anthropic OAuth pool: the pool rotates only on 401/403/429, so when the failed child identifies one pool account and the failure carries the exact credit-balance refusal (`isAnthropicBillingFailure`), `core-combo.ts` records that account's refusal through the pool's `recordAnthropicAccountRefusal` (the same entitlement backoff a 403 credit refusal gets) and `resolve.ts` omits every target cooldown only while the pool still has an eligible account; a lone account, an exhausted pool, an unidentified account, or any other 402 keeps the provider-scoped cooldown. Regression coverage: `tests/adapters/anthropic/anthropic-combo-account-cooldown.test.ts`. The Anthropic OAuth pool's own all-accounts-cooled 429 is the one unattributed exception: `isAnthropicPoolLocalRefusal` in `core-combo.ts` requires the `anthropic` provider with `authMode: "oauth"`, status 429, no identified failed account, no eligible pool account and a live pool retry-after. The target then still cools, but for the local fallback instead of the restated per-account `Retry-After`, so a resumed or newly added account is not ignored until a 24-hour target cooldown expires. An upstream 429 on an API-key provider or on an identified pool account keeps its full `Retry-After`. Regression coverage: `tests/routing/always-on-429-failover.test.ts`. Cooldown entries in `src/combos/failover.ts` retain the failure status. The quota-only snapshot query records target/expiry pairs for unexpired 429/402 cooldowns using only cooldown map lookups. Its config-aware wrapper in `src/combos/resolve.ts` applies the picker's provider availability and cached quota checks before dispatch; request eligibility runs lazily only when exhaustion considers a 503, with throwing checks treated as ineligible. Responses exhaustion uses this evidence under the [failover contract](transports/responses-failover.md); ordinary cooldown selection and timing stay unchanged. A zero-attempt `combo_unavailable` names why each target was skipped. `comboExclusionReasons` in `src/combos/resolve.ts` derives one coarse label per target from the picker's own checks, in picker order: `provider missing`, `provider disabled`, `quota exhausted`, `cooldown s`, then `ineligible for this request` when the caller's eligibility predicate refuses it or throws. `NoAvailableComboTargetsError` and `comboUnavailable` append them to the message; the label carries only the configured provider/model, never account, credential or request data. Coverage: `tests/responses/responses-combo-exhausted-error.test.ts`. +`src/combos/failover.ts` uses a 10-minute fallback for a spent account usage window (codes `usage_limit_exceeded`, `usage_limit_reached`, `1308`, or `usage limit reached` / `usage limit has been reached` / `token-plan quota has been exhausted` prose, including HTTP 502) and for provider-scoped credential or billing failure codes such as `invalid_api_key` and `insufficient_quota`. This duration does not change failure classification or cooldown scope; upstream retry/reset signals and configured durations retain precedence. It caps explicit upstream `Retry-After` target cooldowns at 24 hours while reset-derived, configured, and fallback cooldowns remain capped at 10 minutes. `src/combos/resolve.ts` omits a target cooldown for a 429 only when the failed child identifies one account in an Anthropic OAuth or canonical Codex pool and that account has a live pool cooldown, or identifies one account in a generic OAuth pool that still has an eligible sibling. `src/server/responses/core-combo.ts` checks the pool's health record before passing that attribution; for a generic OAuth pool it first hands the account (matched through its `accountLogLabel`) to `rotateGenericOAuthAccountOn429`, which cools it under the pool's own Retry-After policy even when the per-request account budget ended rotation before that account was recorded, and attributes the failure only when the rotator names a sibling. Unknown accounts, ambiguous labels, a lone or fully cooled generic OAuth pool, API-key providers, and other failures keep their ordinary target cooldown. An API-key pool needs no attribution: its 429 rotation walks every key inside the attempt, so a 429 reaches the combo only once every key is cooled. Regression coverage: `tests/oauth/generic-oauth-combo-account-cooldown.test.ts`. A 402 is the provider-scoped twin for an Anthropic OAuth pool: the pool rotates only on 401/403/429, so when the failed child identifies one pool account and the failure carries the exact credit-balance refusal (`isAnthropicBillingFailure`), `core-combo.ts` records that account's refusal through the pool's `recordAnthropicAccountRefusal` (the same entitlement backoff a 403 credit refusal gets) and `resolve.ts` omits every target cooldown only while the pool still has an eligible account; a lone account, an exhausted pool, an unidentified account, or any other 402 keeps the provider-scoped cooldown. Regression coverage: `tests/adapters/anthropic/anthropic-combo-account-cooldown.test.ts`. The Anthropic OAuth pool's own all-accounts-cooled 429 is the one unattributed exception: `isAnthropicPoolLocalRefusal` in `core-combo.ts` requires the `anthropic` provider with `authMode: "oauth"`, status 429, no identified failed account, no eligible pool account and a live pool retry-after. The target then still cools, but for the local fallback instead of the restated per-account `Retry-After`, so a resumed or newly added account is not ignored until a 24-hour target cooldown expires. An upstream 429 on an API-key provider or on an identified pool account keeps its full `Retry-After`. Regression coverage: `tests/routing/always-on-429-failover.test.ts`. Cooldown entries in `src/combos/failover.ts` retain the failure status. The quota-only snapshot query records target/expiry pairs for unexpired 429/402 cooldowns using only cooldown map lookups. Its config-aware wrapper in `src/combos/resolve.ts` applies the picker's provider availability and cached quota checks before dispatch; request eligibility runs lazily only when exhaustion considers a 503, with throwing checks treated as ineligible. Responses exhaustion uses this evidence under the [failover contract](transports/responses-failover.md); ordinary cooldown selection and timing stay unchanged. Opt-in `cooldownReprobe: { enabled: true, intervalMs }` in `src/combos/resolve.ts` permits an initial non-preview request to clear all-cooled eligible targets once per configured interval (default 300000 ms, range 1000..86400000). The synchronous reservation lives in selection state and resets on proxy restart or selection-state reset; Only target keys eligible for the current request are cleared; other target cooldowns are preserved. Route-level selection without request eligibility disables reprobes, as does the in-flight retry-after-cooldown path, which waits for normal expiry. Nested wait-path selections cannot start a second reprobe. Preparation first checks available siblings without evaluating cooled targets; a throwing eligibility predicate is ineligible for recovery. Encrypted-task recovery relaxes only payload readability and retains metering, JEV, reasoning-replay and protocol-lane eligibility. Its recovery selection may reserve a reprobe only after Responses ingress, thread-spawn, enabled recovery configuration and non-child-attempt admission pass; refused recovery preserves cooldowns and the reservation. New failures record cooldowns normally, and provider quota, account admission, payload eligibility, metered-spend and cancellation checks remain authoritative. There is no timer; existing send bounds remain. The opt-in may retry a target before its server Retry-After. CLI `--cooldown-reprobe on|off` and `--cooldown-reprobe-interval` control it; omitted management fields preserve it on unrelated saves. Tests: `tests/codex-integration/combo-cooldown-reprobe.test.ts` and `tests/cli/cli-combo-parity.test.ts`. A zero-attempt `combo_unavailable` names why each target was skipped. `comboExclusionReasons` in `src/combos/resolve.ts` derives one coarse label per target from the picker's own checks, in picker order: `provider missing`, `provider disabled`, `quota exhausted`, `cooldown s`, then `ineligible for this request` when the caller's eligibility predicate refuses it or throws. `NoAvailableComboTargetsError` and `comboUnavailable` append them to the message; the label carries only the configured provider/model, never account, credential or request data. Coverage: `tests/responses/responses-combo-exhausted-error.test.ts`. ## Combo default effort precedence diff --git a/tests/cli/cli-combo-parity.test.ts b/tests/cli/cli-combo-parity.test.ts index 73fbd337dd3..28f2936092c 100644 --- a/tests/cli/cli-combo-parity.test.ts +++ b/tests/cli/cli-combo-parity.test.ts @@ -136,6 +136,38 @@ function realRuntime(refresh: CatalogDisposition = { status: "committed", change } describe("integrated combo extension and actual management persistence", () => { + test("cooldown reprobe opt-in, interval, carry and explicit off survive persistence", async () => { + const f = realRuntime(); + expect(await handleComboCommand(["set", "saved", "--cooldown-reprobe", "on", "--cooldown-reprobe-interval", "120000", "--json"], f.deps)).toBe(0); + expect(f.config.combos!.saved!.cooldownReprobe).toEqual({ enabled: true, intervalMs: 120000 }); + expect(await handleComboCommand(["set", "saved", "--display-name", "Retained", "--json"], f.deps)).toBe(0); + expect(f.config.combos!.saved!.cooldownReprobe).toEqual({ enabled: true, intervalMs: 120000 }); + // A GUI or older API client omitting the field also carries the saved opt-in. + const response = await handleComboRoutes({ + req: new Request("http://localhost/api/combos", { method: "PUT", body: JSON.stringify({ + id: "saved", combo: { targets: [target], strategy: "failover", defaultEffort: "high" }, + }) }), url: new URL("http://localhost/api/combos"), config: f.config, deps: {}, version: "fixture", + trustedLoopbackIngress: true, guiSessionIssuance: null, + convergeCodexCatalog: async () => ({ status: "committed", changed: false, degraded: false, notices: [] }), + syncClaudeAgentDefsBestEffort: async () => {}, + }); + expect(response!.status).toBe(200); + expect(f.config.combos!.saved!.cooldownReprobe?.enabled).toBe(true); + expect(await handleComboCommand(["set", "saved", "--cooldown-reprobe", "off", "--json"], f.deps)).toBe(0); + expect(f.config.combos!.saved!.cooldownReprobe).toBeUndefined(); + }); + test("interval alone does not enable cooldown recovery", async () => { + const f = realRuntime(); + expect(await handleComboCommand(["set", "saved", "--cooldown-reprobe-interval", "1000", "--json"], f.deps)).toBe(0); + expect(f.config.combos!.saved!.cooldownReprobe).toBeUndefined(); + }); + test("invalid cooldown reprobe flags refuse before contacting the proxy", async () => { + for (const args of [["--cooldown-reprobe", "yes"], ["--cooldown-reprobe-interval", "0"], ["--cooldown-reprobe-interval", "1.5"], ["--cooldown-reprobe-interval", "86400001"]]) { + const f = realRuntime(); + expect(await handleComboCommand(["set", "saved", ...args], f.deps)).toBe(2); + expect(f.calls).toEqual([]); + } + }); test("a target-file replacement preserves unrelated strategy and public identity", async () => { const f = realRuntime(); Object.assign(f.config.combos!.saved!, { strategy: "round-robin", stickyLimit: 3, alias: "my-combo", displayName: "My combo" }); diff --git a/tests/codex-integration/combo-cooldown-reprobe.test.ts b/tests/codex-integration/combo-cooldown-reprobe.test.ts new file mode 100644 index 00000000000..03284daeb6a --- /dev/null +++ b/tests/codex-integration/combo-cooldown-reprobe.test.ts @@ -0,0 +1,188 @@ +import { describe, test, expect } from 'bun:test'; +import { pickComboTarget, pickComboTargetWithWait, advanceComboAfterFailure, clearComboSelectionState } from '../../src/combos/resolve'; +import { clearComboTargetCooldowns, coolComboTarget, isComboTargetInCooldown } from '../../src/combos/failover'; +import { comboConfigIssues, normalizeComboConfig } from '../../src/combos/types'; +import { routeModel } from '../../src/router'; +import type { OcxConfig, OcxComboConfig } from '../../src/types'; + +const targets = [{ provider: 'openai', model: 'test-a', weight: 1 }, { provider: 'openai', model: 'test-b', weight: 1 }]; +const now = 1_000_000; +let sequence = 0; +function setup(reprobe: OcxComboConfig['cooldownReprobe'] = { enabled: true }) { + const id = `reprobe-${++sequence}`; + const config = { providers: { openai: { baseUrl: 'https://example.invalid', apiKey: 'test' } }, + combos: { [id]: { strategy: 'failover', targets, ...(reprobe ? { cooldownReprobe: reprobe } : {}) } } } as OcxConfig; + cool(id); + return { id, config }; +} +function cool(id: string, time = now) { + for (const target of targets) coolComboTarget(id, target, { now: time, retryAfter: '36000', status: 429 }); +} + +describe('opt-in cooldown recovery', () => { + test('omitted and explicitly disabled preserve current behavior', () => { + for (const value of [undefined, { enabled: false }]) { + const { id, config } = setup(value); + if (!value) delete config.combos![id]!.cooldownReprobe; + expect(pickComboTarget(config, id, { now })).toBeNull(); + expect(isComboTargetInCooldown(id, targets[0]!, now)).toBe(true); + } + }); + test('one ordered pass; errors re-cool; no second pass before default interval', () => { + const { id, config } = setup(); + const first = pickComboTarget(config, id, { now })!; + expect(first.target.model).toBe('test-a'); + const second = advanceComboAfterFailure(config, first, { now, status: 429, retryAfter: '36000', cooldownScope: 'target' })!; + expect(second.target.model).toBe('test-b'); + expect(advanceComboAfterFailure(config, second, { now, status: 402, retryAfter: '36000', cooldownScope: 'target' })).toBeNull(); + expect(pickComboTarget(config, id, { now: now + 299999 })).toBeNull(); + expect(pickComboTarget(config, id, { now: now + 300000 })!.target.model).toBe('test-a'); + }); + test('interval is configurable and reservations are isolated by combo', () => { + const a = setup({ enabled: true, intervalMs: 1000 }); + const b = setup({ enabled: true, intervalMs: 1000 }); + expect(pickComboTarget(a.config, a.id, { now })).not.toBeNull(); + cool(a.id); + expect(pickComboTarget(a.config, a.id, { now: now + 999 })).toBeNull(); + expect(pickComboTarget(b.config, b.id, { now })).not.toBeNull(); + expect(pickComboTarget(a.config, a.id, { now: now + 1000 })).not.toBeNull(); + }); + test('preview and in-flight exclusions do not reopen targets', () => { + const { id, config } = setup(); + expect(pickComboTarget(config, id, { now, preview: true })).toBeNull(); + expect(pickComboTarget(config, id, { now, exclude: ['openai/test-a'] })).toBeNull(); + expect(isComboTargetInCooldown(id, targets[0]!, now)).toBe(true); + }); + test('available targets prevent a reset of siblings', () => { + const { id, config } = setup(); + pickComboTarget(config, id, { now }); + coolComboTarget(id, targets[0]!, { now, retryAfter: '36000', status: 402 }); + expect(pickComboTarget(config, id, { now })!.target.model).toBe('test-b'); + expect(isComboTargetInCooldown(id, targets[0]!, now)).toBe(true); + }); + test('recovery preserves cooldowns for targets ineligible for this request', () => { + const { id, config } = setup(); + const pick = pickComboTarget(config, id, { now, eligible: target => target.model === 'test-a' }); + expect(pick!.target.model).toBe('test-a'); + expect(isComboTargetInCooldown(id, targets[0]!, now)).toBe(false); + expect(isComboTargetInCooldown(id, targets[1]!, now)).toBe(true); + }); + for (const wait of [false, true]) { + test(`a cooled throwing predicate cannot block an available sibling (wait=${wait})`, async () => { + const { id, config } = setup(); + clearComboTargetCooldowns(id, ['openai/test-b']); + let cooledChecks = 0; + const eligible = (target: typeof targets[number]) => { + if (target.model === 'test-a') { cooledChecks++; throw new Error('unavailable metadata'); } + return true; + }; + const pick = wait + ? await pickComboTargetWithWait(config, id, { now, eligible, waitForCooldownMs: 0 }) + : pickComboTarget(config, id, { now, eligible }); + expect(pick!.target.model).toBe('test-b'); + expect(cooledChecks).toBe(0); + expect(isComboTargetInCooldown(id, targets[0]!, now)).toBe(true); + }); + test(`reprobe treats a throwing predicate as ineligible (wait=${wait})`, async () => { + const { id, config } = setup(); + const eligible = (target: typeof targets[number]) => { + if (target.model === 'test-a') throw new Error('unavailable metadata'); + return true; + }; + const pick = wait + ? await pickComboTargetWithWait(config, id, { now, eligible, waitForCooldownMs: 0 }) + : pickComboTarget(config, id, { now, eligible }); + expect(pick!.target.model).toBe('test-b'); + expect(isComboTargetInCooldown(id, targets[0]!, now)).toBe(true); + }); + } + test('scoped cooldown clearing preserves other targets and combos', () => { + const a = setup(); + const b = setup(); + clearComboTargetCooldowns(a.id, []); + expect(isComboTargetInCooldown(a.id, targets[0]!, now)).toBe(true); + clearComboTargetCooldowns(a.id, ['openai/test-a']); + expect(isComboTargetInCooldown(a.id, targets[0]!, now)).toBe(false); + expect(isComboTargetInCooldown(a.id, targets[1]!, now)).toBe(true); + expect(isComboTargetInCooldown(b.id, targets[0]!, now)).toBe(true); + clearComboTargetCooldowns(a.id); + expect(isComboTargetInCooldown(a.id, targets[1]!, now)).toBe(false); + expect(isComboTargetInCooldown(b.id, targets[1]!, now)).toBe(true); + }); + test('explicit opt-out preserves cooldowns without consuming a recovery reservation', async () => { + const { id, config } = setup(); + expect(pickComboTarget(config, id, { now, allowCooldownReprobe: false })).toBeNull(); + expect(await pickComboTargetWithWait(config, id, { + now, waitForCooldownMs: 0, allowCooldownReprobe: false, + })).toBeNull(); + expect(isComboTargetInCooldown(id, targets[0]!, now)).toBe(true); + expect(await pickComboTargetWithWait(config, id, { now, waitForCooldownMs: 0 })).not.toBeNull(); + }); + test('in-flight retries wait for normal cooldown expiry even after the reprobe interval', async () => { + const { id, config } = setup({ enabled: true, intervalMs: 1000 }); + expect(pickComboTarget(config, id, { now })).not.toBeNull(); + for (const target of targets) coolComboTarget(id, target, { now, retryAfter: '2', status: 429 }); + const sleeps: number[] = []; + const pick = await pickComboTargetWithWait(config, id, { + now: now + 1000, waitForCooldownMs: 1000, allowCooldownReprobe: false, + sleep: async delay => { sleeps.push(delay); }, + }); + expect(sleeps).toEqual([1000]); + expect(pick!.target.model).toBe('test-a'); + }); + test('wait path reserves synchronously across concurrent callers', async () => { + const { id, config } = setup(); + const first = await pickComboTargetWithWait(config, id, { now, waitForCooldownMs: 0 }); + expect(first!.target.model).toBe('test-a'); + cool(id); + const results = await Promise.all(Array.from({ length: 4 }, () => + pickComboTargetWithWait(config, id, { now, waitForCooldownMs: 0 }))); + expect(results).toEqual([null, null, null, null]); + }); + test('eligibility, disabled providers, and cancellation remain enforced', async () => { + const { id, config } = setup(); + expect(pickComboTarget(config, id, { now, eligible: () => false })).toBeNull(); + expect(isComboTargetInCooldown(id, targets[0]!, now)).toBe(true); + const abort = new AbortController(); abort.abort(); + expect(await pickComboTargetWithWait(config, id, { now, waitForCooldownMs: 0, abortSignal: abort.signal })).toBeNull(); + expect(isComboTargetInCooldown(id, targets[0]!, now)).toBe(true); + config.providers.openai!.disabled = true; + expect(pickComboTarget(config, id, { now })).toBeNull(); + }); + test('selection-state reset releases reservation', () => { + const { id, config } = setup(); + pickComboTarget(config, id, { now }); cool(id); + clearComboSelectionState(id); + expect(pickComboTarget(config, id, { now })).not.toBeNull(); + }); + test('route-level selection cannot reopen targets without request eligibility', () => { + const { id, config } = setup(); + // Router uses the real clock; keep these cooldowns ahead of it. + cool(id, Date.now()); + expect(() => routeModel(config, `combo/${id}`)).toThrow(); + expect(isComboTargetInCooldown(id, targets[0]!, Date.now())).toBe(true); + expect(isComboTargetInCooldown(id, targets[1]!, Date.now())).toBe(true); + expect(pickComboTarget(config, id, { now: Date.now(), eligible: target => target.model === 'test-a' })).not.toBeNull(); + expect(isComboTargetInCooldown(id, targets[1]!, Date.now())).toBe(true); + }); +}); + +describe('configuration validation', () => { + test('defaults to disabled, and enabled defaults to five minutes', () => { + expect(normalizeComboConfig({ targets }).cooldownReprobe).toBeUndefined(); + expect(normalizeComboConfig({ targets, cooldownReprobe: { enabled: false } }).cooldownReprobe).toBeUndefined(); + expect(normalizeComboConfig({ targets, cooldownReprobe: { enabled: true } }).cooldownReprobe).toEqual({ enabled: true, intervalMs: 300000 }); + }); + test('accepts interval bounds and rejects invalid/unknown settings', () => { + const providers = { openai: { baseUrl: 'https://example.invalid' } }; + for (const intervalMs of [1000, 86400000]) { + expect(comboConfigIssues('valid', { targets, cooldownReprobe: { enabled: true, intervalMs } }, providers)).toEqual([]); + } + for (const cooldownReprobe of [null, [], true, {}, { enabled: 'yes' }, + { enabled: true, intervalMs: 0 }, { enabled: true, intervalMs: 1000.5 }, + { enabled: true, intervalMs: 86400001 }, { enabled: true, intervalMs: '300000' }, + { enabled: true, extra: true }]) { + expect(comboConfigIssues('invalid', { targets, cooldownReprobe }, providers).some(i => i.path[0] === 'cooldownReprobe')).toBe(true); + } + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 31970b8bde5..ffd401ece74 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -603,6 +603,7 @@ "coding-agent-tool-result-images.test.ts": "adapters", "cold-spawn-warmup.test.ts": "ci-workflows", "combo-authoritative-reset.test.ts": "codex-integration", "combo-codex-exhaustion-cooldown.test.ts": "codex-integration", + "combo-cooldown-reprobe.test.ts": "codex-integration", "combo-permanent-failure-cooldown.test.ts": "codex-integration", "combo-reset-window-model-scope.test.ts": "codex-integration", "combo-child-headers.test.ts": "routing", "combo-last-resort.test.ts": "codex-integration", "combo-management-api.test.ts": "routing", diff --git a/tests/server/agent-task-recovery-combo.test.ts b/tests/server/agent-task-recovery-combo.test.ts index 7df943326bc..828fd6e82cb 100644 --- a/tests/server/agent-task-recovery-combo.test.ts +++ b/tests/server/agent-task-recovery-combo.test.ts @@ -10,7 +10,8 @@ import { } from "../../src/responses/state"; import { resetAgentTaskRecoveryState } from "../../src/server/responses/agent-task-recovery"; import { agentTaskRecoveryCacheSnapshotForTests } from "../../src/server/responses/agent-task-recovery-cache"; -import { clearComboTargetCooldowns, coolComboTarget } from "../../src/combos/failover"; +import { clearComboTargetCooldowns, coolComboTarget, isComboTargetInCooldown } from "../../src/combos/failover"; +import { clearComboSelectionState } from "../../src/combos/resolve"; import { clearCachedProviderQuotas, setCachedProviderQuotaForTests, @@ -70,6 +71,7 @@ describe("combo path encrypted agent task recovery", () => { resetAgentTaskRecoveryState(); clearCachedProviderQuotas(); clearComboTargetCooldowns(); + clearComboSelectionState(); }); afterEach(() => { @@ -77,6 +79,7 @@ describe("combo path encrypted agent task recovery", () => { resetAgentTaskRecoveryState(); clearCachedProviderQuotas(); clearComboTargetCooldowns(); + clearComboSelectionState(); clearResponseStateForTests(); // Released after the state flush and before the directory holding it is removed. releaseSpendHome?.(); @@ -127,6 +130,78 @@ describe("combo path encrypted agent task recovery", () => { expect(snapshot).not.toContain(responsePayload.id!); }); + test.each([false, true])("encrypted recovery keeps replay-incompatible targets excluded (reprobe=%s)", async (reprobe) => { + const strict = { provider: "strict", model: "deepseek-v4-flash" }; + const backup = { provider: "xai", model: "grok-4.5" }; + const config = comboConfig([strict, backup]); + config.providers.strict = { + adapter: "openai-responses", baseUrl: "https://strict.example.test/v1", apiKey: "synthetic-key", + preserveResponsesReasoningContent: true, requiresAdjacentResponsesToolResults: true, + }; + config.combos!.routed!.cooldownReprobe = { enabled: reprobe }; + if (reprobe) { + for (const target of [strict, backup]) coolComboTarget("routed", target, { cooldownMs: 60_000 }); + } + const urls: string[] = []; + globalThis.fetch = (async (input) => { + const url = String(input); + urls.push(url); + return url.includes("chatgpt.com") + ? new Response(recoverySse("recovered assignment"), { headers: { "content-type": "text/event-stream" } }) + : providerCompletion(); + }) as typeof fetch; + const headers = codexHeaders(); + headers.set("session_id", `encrypted-replay-${reprobe}`); + const seeded = await post(config, "xai/grok-4.5", [{ role: "user", content: "seed identity" }], headers); + expect(seeded.status).toBe(200); + await seeded.text(); + urls.length = 0; + const response = await post(config, "combo/routed", [ + { type: "reasoning", id: "rs_foreign", summary: [], encrypted_content: "foreign-blob", content: [] }, + { type: "function_call", call_id: "call_1", name: "get_weather", arguments: "{}" }, + { type: "function_call_output", call_id: "call_1", output: "rain" }, + ...encryptedInput(), + ], headers, undefined, { tools: [{ type: "function", name: "get_weather", parameters: { type: "object", properties: {} } }] }); + await response.text(); + expect(response.status).toBe(200); + expect(urls).toHaveLength(2); + expect(urls[0]).toContain("chatgpt.com"); + expect(urls[1]).not.toContain("strict.example.test"); + if (reprobe) expect(isComboTargetInCooldown("routed", strict)).toBe(true); + }); + + test.each(["non-spawn", "disabled-recovery"] as const)("refused %s recovery preserves cooldowns and the reprobe reservation", async (reason) => { + const target = { provider: "xai", model: "grok-4.5" }; + const config = comboConfig([target]); + config.combos!.routed!.cooldownReprobe = { enabled: true, intervalMs: 86400000 }; + coolComboTarget("routed", target, { cooldownMs: 60_000 }); + const headers = codexHeaders(); + if (reason === "non-spawn") headers.delete("x-openai-subagent"); + else config.agentTaskRecovery = { enabled: false }; + const urls: string[] = []; + globalThis.fetch = (async (input) => { + const url = String(input); + urls.push(url); + return url.includes("chatgpt.com") + ? new Response(recoverySse("admitted assignment"), { headers: { "content-type": "text/event-stream" } }) + : providerCompletion(); + }) as typeof fetch; + const refused = await post(config, "combo/routed", encryptedInput(), headers); + await refused.text(); + expect(refused.status).toBe(503); + expect(urls).toEqual([]); + expect(isComboTargetInCooldown("routed", target)).toBe(true); + expect(agentTaskRecoveryCacheSnapshotForTests()).toEqual({ entries: 0, bytes: 0 }); + + // An admitted request immediately afterwards must still own the first recovery pass. + config.agentTaskRecovery = { enabled: true }; + const admitted = await post(config, "combo/routed", encryptedInput(), codexHeaders()); + await admitted.text(); + expect(admitted.status).toBe(200); + expect(urls).toHaveLength(2); + expect(isComboTargetInCooldown("routed", target)).toBe(false); + }); + test("rejects an all-disabled combo before recovery creates or caches plaintext", async () => { const assignment = "MUST-NOT-BE-PRODUCED-OR-CACHED"; const config = comboConfig([{ provider: "xai", model: "grok-4.5" }]); diff --git a/tests/server/combo-metered-consent.test.ts b/tests/server/combo-metered-consent.test.ts index afa54cc8633..4cc85ad7bb2 100644 --- a/tests/server/combo-metered-consent.test.ts +++ b/tests/server/combo-metered-consent.test.ts @@ -125,7 +125,7 @@ test("a subagent fallback that collapses a combo onto its metered target needs k const config = (consent: boolean): OcxConfig => ({ port: 16389, defaultProvider: "primary", providers: { primary: provider("primary"), subscription: provider("subscription"), paid: provider("paid") }, - combos: { chain: { alias: "chain-model", targets: [{ provider: "subscription", model: "m" }, { provider: "paid", model: "m", metered: true }] } }, + combos: { chain: { alias: "chain-model", cooldownReprobe: { enabled: true }, targets: [{ provider: "subscription", model: "m" }, { provider: "paid", model: "m", metered: true }] } }, subagentModelFallback: ["chain-model"], apiKeys: [{ ...key, allowMeteredComboTargets: consent }], }); diff --git a/tests/server/server-combo-cooldown-recording.test.ts b/tests/server/server-combo-cooldown-recording.test.ts index 337244a62fa..7fb4b9f838d 100644 --- a/tests/server/server-combo-cooldown-recording.test.ts +++ b/tests/server/server-combo-cooldown-recording.test.ts @@ -95,6 +95,26 @@ afterEach(async () => { removeTreeWithRetry(home); } }); +test("an in-flight cooldown retry cannot initiate an opt-in reprobe", async () => { + let hits = 0; + const server = serve(() => { + hits += 1; + return hits === 1 + ? Response.json({ error: { message: "rate limited" } }, { + status: 429, headers: { "retry-after": "2" }, + }) + : chatSuccess("unexpected immediate retry", "m1"); + }); + const cfg = comboConfig({ a: provider("openai-chat", baseUrl(server), "synthetic-key") }, [target], { + cooldownReprobe: { enabled: true, intervalMs: 1000 }, waitForCooldownMs: 100, + }); + const response = await post(cfg); + await response.text(); + expect(response.status).toBe(429); + expect(hits).toBe(1); + expect(isComboTargetInCooldown("free", target)).toBe(true); +}); + test("cooldown recording distinguishes a successful write from a stale removed writer", () => { expect(coolComboTarget("free", target, { cooldownMs: 1000 })).toBe(true); reconcileComboTargetCooldowns(removalContext()); From 415078c34303b7057150c4023a276c7c7bca48a4 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 26 Oct 2026 18:24:36 +0900 Subject: [PATCH 072/101] test(cli): let the forged sibling-env start bind its own port (#7740) * test(cli): let the forged sibling-env start bind its own port freeLoopbackPort() releases its probe before the pinned child binds; a port taken in between makes the pinned start wait out port reclaim past the 45 s Windows deadline with no runtime record (dev run 40183909309). The forged-env case checks sibling admission, not a port number, so it now binds through config.port 0. Timeouts report the child's output and last record. * test(cli): keep sibling-start diagnostics secret-free and early-exit safe Log only allowlisted runtime-record fields (the record carries the per-process attestationSecret), rethrow an early-exit error unchanged since its stderr is already consumed, and catch synchronous Response construction failures in the bounded tail reader. --------- Co-authored-by: lidge-jun --- tests/cli/cli-start-journal-order.test.ts | 76 ++++++++++++++++++----- 1 file changed, 59 insertions(+), 17 deletions(-) diff --git a/tests/cli/cli-start-journal-order.test.ts b/tests/cli/cli-start-journal-order.test.ts index f7a98f1f359..f8a1899b4a7 100644 --- a/tests/cli/cli-start-journal-order.test.ts +++ b/tests/cli/cli-start-journal-order.test.ts @@ -306,13 +306,23 @@ function freeLoopbackPort(): number { return port; } +/** + * Spawn `ocx start` and wait for its runtime record and settled readiness. + * + * `port` pins the listener with `--port`. A pinned port comes from freeLoopbackPort(), which + * releases its probe before the child binds, so another process on the runner can take it in + * between; a hard-pinned start then waits out port reclaim (up to 60 s) before it binds and + * publishes, which outlasts OWNER_WAIT_MS with the child alive and no record. Pass `undefined` + * when the case does not need a predetermined port: the child keeps config.port and binds its + * own, and the runtime record supplies the port it chose. + */ async function startSibling( env: Record, siblingHome: string, - port: number, + port: number | undefined, cwd: string, ): Promise<{ child: ReturnType; runtime: { pid: number; port: number; siblingOfPort?: number } }> { - const child = Bun.spawn([process.execPath, cliPath, "start", "--port", String(port)], { + const child = Bun.spawn([process.execPath, cliPath, "start", ...(port === undefined ? [] : ["--port", String(port)])], { cwd, env, stdout: "pipe", @@ -320,25 +330,56 @@ async function startSibling( }); children.push(child); const runtimePath = join(siblingHome, "runtime-port.json"); - const runtime = await waitFor(async () => { - if (child.exitCode !== null) { - throw new Error(`sibling exited ${child.exitCode}: ${await new Response(child.stderr).text()}`); - } - if (!existsSync(runtimePath)) return null; - try { - const value = JSON.parse(readFileSync(runtimePath, "utf8")) as { pid: number; port: number; siblingOfPort?: number }; - return value.pid === child.pid && value.port === port ? value : null; - } catch { - return null; - } - }, "sibling runtime record"); + // Allowlisted fields only: the record also carries the per-process attestationSecret. + let lastRecord: string | null = null; + let exitedEarly = false; + let runtime: { pid: number; port: number; siblingOfPort?: number }; + try { + runtime = await waitFor(async () => { + if (child.exitCode !== null) { + exitedEarly = true; + throw new Error(`sibling exited ${child.exitCode}: ${await new Response(child.stderr).text()}`); + } + if (!existsSync(runtimePath)) return null; + try { + const value = JSON.parse(readFileSync(runtimePath, "utf8")) as { pid: number; port: number; hostname?: string; siblingOfPort?: number }; + lastRecord = JSON.stringify({ pid: value.pid, port: value.port, hostname: value.hostname, siblingOfPort: value.siblingOfPort }); + const portOk = Number.isInteger(value.port) && value.port >= 1 && value.port <= 65535 + && (port === undefined || value.port === port); + return value.pid === child.pid && portOk ? value : null; + } catch { + return null; + } + }, "sibling runtime record"); + } catch (error) { + // An early exit already reported its stderr and consumed the stream. + if (exitedEarly) throw error; + // Report what the child was doing, which the bare deadline message cannot. + if (child.exitCode === null) child.kill("SIGKILL"); + const tail = async (stream: unknown): Promise => { + try { + return await Promise.race([ + new Response(stream as ReadableStream).text().then(text => text.slice(-4000)), + Bun.sleep(2000).then(() => ""), + ]); + } catch { + return ""; + } + }; + const [stdout, stderr] = await Promise.all([tail(child.stdout), tail(child.stderr)]); + throw new Error( + `${(error as Error).message} (requested port ${port ?? "config"}, pid ${child.pid}, last record ${lastRecord ?? "none"})\n` + + `--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`, + ); + } + const readyPort = runtime.port; // /readyz settles after the startup sync. Without the sibling gate that sync is exactly // what rewrote the shared config.toml, so the bytes are compared only once it has settled, // and it has to settle as "ready": a sibling that wrote nothing has nothing to fail. let settled: string | undefined; await waitFor(async () => { try { - const response = await fetch(`http://127.0.0.1:${port}/readyz`, { signal: AbortSignal.timeout(500) }); + const response = await fetch(`http://127.0.0.1:${readyPort}/readyz`, { signal: AbortSignal.timeout(500) }); const body = await response.json() as { status?: string; pid?: number }; if (body.pid !== child.pid || body.status === undefined || body.status === "pending") return null; settled = body.status; @@ -477,11 +518,12 @@ describe("a sibling instance leaves the live owner's client routing alone", () = test("a direct start cannot claim sibling status with only a forged port env", async () => { const fx = fixture(); const forgedOwnerPort = freeLoopbackPort(); - const childPort = freeLoopbackPort(); + // Sibling admission is the claim here, not a particular listener number, so the child binds + // its own port (config.port 0) instead of a released probe port another process may take. const launched = await startSibling( { ...fx.env, OCX_SIBLING_OF_PORT: String(forgedOwnerPort) }, fx.ocxHome, - childPort, + undefined, fx.root, ); expect(launched.runtime.siblingOfPort).toBeUndefined(); From 833a80499712b99f702cadc3446ad899290ac9cf Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Mon, 26 Oct 2026 09:44:46 +0000 Subject: [PATCH 073/101] fix(launcher): enforce the pinned Bun patch floor for PATH fallback (#7734) Require automatic PATH Bun candidates to meet the full stable version pin within the same major. Preserve explicit overrides and durable artifact policy; add floor regression coverage and align installation documentation. Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> --- .../docs/getting-started/installation.md | 4 +- .../docs/ko/getting-started/installation.md | 4 +- src/lib/bun-path-runtime.mjs | 3 +- structure/ops/docs-and-release.md | 2 +- structure/runtime.md | 2 +- tests/cli/ocx-launcher-runtime.test.ts | 53 +++++++++++++------ 6 files changed, 45 insertions(+), 23 deletions(-) diff --git a/docs-site/src/content/docs/getting-started/installation.md b/docs-site/src/content/docs/getting-started/installation.md index fcb562ff2aa..3ffc2f3effd 100644 --- a/docs-site/src/content/docs/getting-started/installation.md +++ b/docs-site/src/content/docs/getting-started/installation.md @@ -38,8 +38,8 @@ Recent npm versions may block bun's postinstall script (`npm warn install-scripts ... blocked because they are not covered by allowScripts`), which leaves the bundled Bun runtime unprepared. After trying permitted bundled-runtime recovery, the launcher can use an executable Bun from an absolute PATH directory if its -stable version has the pinned major and a minor at least the pinned minor. With the current -1.4.4 dependency, that means 1.4.0 or newer within major 1. Bounded version-policy and identity +stable version has the pinned major and a version at least the pinned version. With the current +1.4.4 dependency, that means 1.4.4 or newer within major 1. Bounded version-policy and identity checks require `bun --version` to match `bun -e`'s `Bun.version`. On POSIX, the launcher checks the whole candidate path, including ancestor directories and components reached through symlinks. Each traversed directory and symlink, and the resolved executable, must be owned by diff --git a/docs-site/src/content/docs/ko/getting-started/installation.md b/docs-site/src/content/docs/ko/getting-started/installation.md index 8333d8cf7b4..4e2ec7dbdbe 100644 --- a/docs-site/src/content/docs/ko/getting-started/installation.md +++ b/docs-site/src/content/docs/ko/getting-started/installation.md @@ -26,8 +26,8 @@ npm install -g @bitkyc08/opencodex install-scripts ... blocked because they are not covered by allowScripts`). 이 경우 번들 Bun 런타임이 준비되지 않을 수 있습니다. 런처는 허용된 번들 복구를 시도한 다음, 절대 경로 PATH 디렉터리의 실행 가능한 Bun을 검증합니다. 정식 버전의 -major가 패키지 pin과 같고 minor가 pin 이상이면 사용할 수 있습니다. 현재 의존성 -1.4.4에서는 1.4.0 이상, 2.0.0 미만입니다. 제한 시간 안에 버전 정책과 identity를 확인하며, +major가 패키지 pin과 같고 버전이 pin 이상이면 사용할 수 있습니다. 현재 의존성 +1.4.4에서는 1.4.4 이상, 2.0.0 미만입니다. 제한 시간 안에 버전 정책과 identity를 확인하며, `bun --version` 출력이 `bun -e`로 확인한 `Bun.version`과 같아야 합니다. POSIX에서는 런처가 상위 디렉터리와 심볼릭 링크를 따라 도달하는 구성 요소를 포함한 후보 경로 전체를 검사합니다. 검사하는 각 디렉터리와 심볼릭 링크, 최종 실행 파일은 root 또는 런처의 유효 사용자 소유여야 합니다. diff --git a/src/lib/bun-path-runtime.mjs b/src/lib/bun-path-runtime.mjs index 20bbf7cd140..ebd8494c524 100644 --- a/src/lib/bun-path-runtime.mjs +++ b/src/lib/bun-path-runtime.mjs @@ -125,7 +125,8 @@ export function findPathBun({ if (!validate(path)) continue; const version = probe(path, ["--version"]); const candidate = stableVersion(version); - if (!candidate || candidate[0] !== pin[0] || candidate[1] < pin[1]) continue; + if (!candidate || candidate[0] !== pin[0] || candidate[1] < pin[1] + || (candidate[1] === pin[1] && candidate[2] < pin[2])) continue; if (probe(path, ["-e", IDENTITY_PROBE]) !== version) continue; return { path, version }; } catch { diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index ef73f928ccc..10405f12f68 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -345,7 +345,7 @@ Invariants: - `bin/ocx.mjs` selects explicit override, bundled Bun, allowed installer recovery, then validated PATH Bun. `src/lib/bun-path-runtime.mjs` requires absolute entries, canonical regular/executable files, the >=1 MB gate, and rejects group/world-writable resolved files or parent directories on POSIX. - Bounded version-policy and identity checks require `--version` to match `-e`'s `Bun.version`, a stable version with the pinned major and minor ≥ pinned minor (currently 1.4.0 ≤ version < 2.0.0). + Bounded version-policy and identity checks require `--version` to match `-e`'s `Bun.version`, a stable version with the pinned major at or above the pinned version (currently 1.4.4 ≤ version < 2.0.0). PATH selection stamps `process`; failure may name an installed Desktop CLI without executing it. The [Windows service wrapper](#windows-service-wrapper-and-incomplete-updates) keeps its own placeholder wait gate; updater inspection never runs installer recovery. - `package.json` carries `"trustedDependencies": ["bun"]` so `bun install` runs the dependency's diff --git a/structure/runtime.md b/structure/runtime.md index bc6383fb32c..16f14c55e95 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -104,7 +104,7 @@ does not perform OAuth, and runtime credential resolution rereads the owned sour | Path | Responsibility | | --- | --- | -| `bin/ocx.mjs` | Published npm `bin` entry (Node shim). An inherited explicit `BUN_RUNTIME_TRANSPILER_CACHE_PATH` is scoped to a `bun-` leaf beneath the configured directory — appended on a version change, kept unchanged when the path already ends in that leaf — for the bundled or PATH runtime it starts (`src/lib/bun-transpiler-cache-scope.mjs`), because another Bun version sharing the directory evicts its entries. Before project dotenv: explicit Bun override, bundle, permitted installer recovery, then `src/lib/bun-path-runtime.mjs` validates absolute PATH Bun candidates with canonical regular/executable and size checks, POSIX validation of every original and symlink-expanded ancestor (root or effective-user ownership; group/world write permission is refused in every ancestor, including shared sticky directories), plus non-writable resolved files and final parent directories and a canonical realpath recheck that permits case-variant spelling, and bounded version-policy and identity checks (`--version` must match `-e`'s `Bun.version`; stable version has the dependency pin's major and minor ≥ pinned minor); PATH selection stamps `process` with its canonical path and prints a version-only stderr notice. Failure names an executable macOS Desktop CLI when present without delegating. The proof-bound Anthropic snapshot, signal/exit propagation and zero-install updater inspection stay intact. `src/cli/version-skew-notice.ts`, called from `src/cli/root.ts`, makes one identity-checked 200 ms advisory probe for start/stop/restart/update/service, excluding JSON/help/internal; Node-managed update bypasses this notice. Before package repair or Bun resolution, `src/lib/desktop-cli-record.mjs` validates the bounded settled Desktop command record before JSON (POSIX private ownership/modes, no symlinks, matching no-follow nonblocking descriptor and macOS ACL checks; failures are `record-unsafe`) and `src/lib/desktop-cli-handoff.mjs` delegates eligible POSIX commands to its validated target with a regenerated argv proof, inherited IO and mirrored exit/signal. Windows always retains the package path and selects Desktop through user `Path` order. Update, removal, internal inspection and `OCX_NO_DESKTOP_HANDOFF=1` retain the package path; only target ENOENT confirmed by a post-spawn `lstat` resumes it after selection. Command selection is separate from runtime/service authority; see [command selection](cli-management.md#ocx-command-selection). | +| `bin/ocx.mjs` | Published npm `bin` entry (Node shim). An inherited explicit `BUN_RUNTIME_TRANSPILER_CACHE_PATH` is scoped to a `bun-` leaf beneath the configured directory — appended on a version change, kept unchanged when the path already ends in that leaf — for the bundled or PATH runtime it starts (`src/lib/bun-transpiler-cache-scope.mjs`), because another Bun version sharing the directory evicts its entries. Before project dotenv: explicit Bun override, bundle, permitted installer recovery, then `src/lib/bun-path-runtime.mjs` validates absolute PATH Bun candidates with canonical regular/executable and size checks, POSIX validation of every original and symlink-expanded ancestor (root or effective-user ownership; group/world write permission is refused in every ancestor, including shared sticky directories), plus non-writable resolved files and final parent directories and a canonical realpath recheck that permits case-variant spelling, and bounded version-policy and identity checks (`--version` must match `-e`'s `Bun.version`; stable version has the dependency pin's major and is at least the pinned version); PATH selection stamps `process` with its canonical path and prints a version-only stderr notice. Failure names an executable macOS Desktop CLI when present without delegating. The proof-bound Anthropic snapshot, signal/exit propagation and zero-install updater inspection stay intact. `src/cli/version-skew-notice.ts`, called from `src/cli/root.ts`, makes one identity-checked 200 ms advisory probe for start/stop/restart/update/service, excluding JSON/help/internal; Node-managed update bypasses this notice. Before package repair or Bun resolution, `src/lib/desktop-cli-record.mjs` validates the bounded settled Desktop command record before JSON (POSIX private ownership/modes, no symlinks, matching no-follow nonblocking descriptor and macOS ACL checks; failures are `record-unsafe`) and `src/lib/desktop-cli-handoff.mjs` delegates eligible POSIX commands to its validated target with a regenerated argv proof, inherited IO and mirrored exit/signal. Windows always retains the package path and selects Desktop through user `Path` order. Update, removal, internal inspection and `OCX_NO_DESKTOP_HANDOFF=1` retain the package path; only target ENOENT confirmed by a post-spawn `lstat` resumes it after selection. Command selection is separate from runtime/service authority; see [command selection](cli-management.md#ocx-command-selection). | | `src/lib/bun-runtime.ts` | Bundled-Bun resolution: `isRealBunBinary()` (size gate vs the ~450-byte placeholder stub), `bundledBunPath()`, and `durableBunPath()` (path baked into service/shim artifacts). Durable selection accepts only the source/path pair already stamped for the running executable; it never re-reads a project-dotenv `OPENCODEX_BUN_PATH`. | | `src/lib/plain-data.ts` | Detached copies for a consumer that must not observe later edits. Descriptor-based reads, including array elements, so an accessor is refused rather than invoked; refuses cycles, functions, class instances and anything else JSON could not have produced, and returns a copy-or-refusal union rather than degrading silently. Symbol-keyed process bookkeeping is skipped. | | `src/cli/index.ts` | `ocx` / `opencodex` CLI. Lifecycle: init, start, stop, restart, status, sync, restore/eject, gui, service, update. Stop preserves connected hub/Link client integrations; disconnect owns their teardown, and start serves them without re-injecting routing. `restart` refuses an in-place restart requested by a CLI whose version differs from the attested `/healthz` version; a newer CLI may use the guarded standalone update path described below, because an in-place replacement respawns from the live installation; placeholder versions (unknown/0.0.0) stay incomparable and keep the restart path. Configuration: provider, account, models, combo/route, access, integrations, v2. Client launchers: Claude, OpenCode, MiniMax Code, and MiniMax CLI text. The MMX launcher owns a child-lifetime loopback path bridge from the client's hard-coded `/anthropic/v1/messages` path to the canonical `/v1/messages` data plane; the server does not expose an extra auth surface. Diagnostics: doctor, debug, observe, health. Windows adds tray. Hidden `__update-badge` prints the read-only package badge JSON without refresh or shim repair for the npm tray. The full top-level reference is rendered by `src/cli/help.ts`; this table names the groups, not every verb. After help/version and unknown-root early exits, recognized commands run the bounded best-effort Codex-shim auto-restore policy before dispatch. `status`, `doctor`, and `codex-shim status` skip shim auto-repair; `system codex-cli-update` and [command-local messaging](local-messaging.md#command-local-cli) suppress it for their whole namespaces, including malformed invocations. Keeps the `#!/usr/bin/env bun` shebang for from-source dev (`bun run src/cli/index.ts`). `src/cli/init.ts` validates an explicit setup port as a decimal integer from 1 through 65535 before publishing the initial configuration. Only an empty answer selects 10100; malformed input is reported and the port question is asked again, while EOF or SIGINT still cancels without saving. | diff --git a/tests/cli/ocx-launcher-runtime.test.ts b/tests/cli/ocx-launcher-runtime.test.ts index 55c29c25eb5..ded8132ac03 100644 --- a/tests/cli/ocx-launcher-runtime.test.ts +++ b/tests/cli/ocx-launcher-runtime.test.ts @@ -403,7 +403,7 @@ function pathFixtureRoot(prefix: string): string { return trustedFixtureRoot(prefix); } -function pathProbeFixture(platform: NodeJS.Platform = "linux", stdout = "1.4.0\n") { +function pathProbeFixture(platform: NodeJS.Platform = "linux", stdout = "1.4.2\n") { const calls: Array<{ path: string; args: string[]; timeout: number; shell: boolean; maxBuffer: number }> = []; let clock = 0; const io: PathBunIo = { @@ -432,7 +432,7 @@ function pathProbeFixture(platform: NodeJS.Platform = "linux", stdout = "1.4.0\n } describe("validated PATH Bun policy", () => { - test.each(["1.4.0", "1.4.2", "1.5.0", "1.99.999", "1.4.0+build.1"])("accepts stable %s on the pinned major", version => { + test.each(["1.4.2", "1.4.3", "1.5.0", "1.99.999", "1.4.2+build.1"])("accepts stable %s on the pinned major", version => { const fixture = pathProbeFixture("linux", version + "\n"); expect(fixture.find({ PATH: "/trusted/bin" })).toEqual({ path: "/trusted/bin/bun", version }); expect(fixture.calls).toEqual([ @@ -440,7 +440,7 @@ describe("validated PATH Bun policy", () => { { path: "/trusted/bin/bun", args: ["-e", "process.stdout.write(String(typeof Bun==='object'&&Bun.version))"], timeout: 750, shell: false, maxBuffer: 4096 }, ]); }); - test.each(["1.3.99", "2.0.0", "0.99.0", "1.4.0-canary", "v1.4.0", "1.4", "01.4.0", "1.4.0\nnoise", "1.4.0\u001b[31m"])("rejects %s", version => { + test.each(["1.3.99", "2.0.0", "0.99.0", "1.4.0", "1.4.1", "1.4.1+build.1", "1.4.0-canary", "v1.4.0", "1.4", "01.4.0", "1.4.0\nnoise", "1.4.0\u001b[31m"])("rejects %s", version => { expect(pathProbeFixture("linux", version).find({ PATH: "/trusted/bin" })).toBeNull(); }); test("skips empty relative and tilde PATH entries before filesystem access", () => { @@ -486,7 +486,7 @@ describe("validated PATH Bun policy", () => { fixture.io.spawnSync = (path, args, options) => { fixture.calls.push({ path, args, timeout: options.timeout, shell: options.shell, maxBuffer: options.maxBuffer }); fixture.advance(100); - return { status: 0, stdout: path === "/last/bun" ? "1.4.0" : "1.3.0" }; + return { status: 0, stdout: path === "/last/bun" ? "1.4.2" : "1.3.0" }; }; expect(fixture.find({ PATH: "/first:/alias:/last" })?.path).toBe("/last/bun"); expect(fixture.calls.map(call => call.timeout)).toEqual([750, 650, 550]); @@ -498,7 +498,28 @@ describe("validated PATH Bun policy", () => { }); test("threshold changes derive from the package pin", () => { expect(pathProbeFixture("linux", "1.4.0").find({ PATH: "/bin" }, "1.5.2")).toBeNull(); - expect(pathProbeFixture("linux", "2.0.0").find({ PATH: "/bin" }, "2.0.9")?.version).toBe("2.0.0"); + expect(pathProbeFixture("linux", "1.4.2").find({ PATH: "/bin" }, "1.4.4")).toBeNull(); + expect(pathProbeFixture("linux", "1.4.4").find({ PATH: "/bin" }, "1.4.4")?.version).toBe("1.4.4"); + expect(pathProbeFixture("linux", "2.0.0").find({ PATH: "/bin" }, "2.0.9")).toBeNull(); + expect(pathProbeFixture("linux", "2.0.9").find({ PATH: "/bin" }, "2.0.9")?.version).toBe("2.0.9"); + expect(pathProbeFixture("linux", "2.1.0").find({ PATH: "/bin" }, "2.0.9")?.version).toBe("2.1.0"); + }); + + // Pin 1.4.4 is the shipped security floor: below-pin patch releases are refused. + test.each(["1.4.0", "1.4.1", "1.4.2", "1.4.3", "1.4.3+build.1"])("pin 1.4.4 rejects below-floor %s", version => { + expect(pathProbeFixture("linux", version).find({ PATH: "/trusted/bin" }, "1.4.4")).toBeNull(); + }); + test.each(["1.4.4", "1.4.4+build.1", "1.5.0"])("pin 1.4.4 accepts at-or-above %s", version => { + expect(pathProbeFixture("linux", version).find({ PATH: "/trusted/bin" }, "1.4.4")?.version).toBe(version); + }); + test("pin 1.4.4 skips a below-floor first PATH entry and selects the next", () => { + const fixture = pathProbeFixture(); + fixture.io.spawnSync = (path, args, options) => { + fixture.calls.push({ path, args, timeout: options.timeout, shell: options.shell, maxBuffer: options.maxBuffer }); + return { status: 0, stdout: path === "/old/bun" ? "1.4.2" : "1.4.4" }; + }; + expect(fixture.find({ PATH: "/old:/new" }, "1.4.4")).toEqual({ path: "/new/bun", version: "1.4.4" }); + expect(fixture.calls.map(call => call.path)).toEqual(["/old/bun", "/new/bun", "/new/bun"]); }); }); @@ -525,7 +546,7 @@ describe("real PATH Bun validation", () => { test("normalizes case-variant macOS PATH spelling after validating its ancestry", () => { const fixture = pathProbeFixture("darwin"); fixture.io.realpath = path => path.replace("/OPT/fixture", "/opt/fixture"); - expect(fixture.find({ PATH: "/OPT/fixture/bin" })).toEqual({ path: "/opt/fixture/bin/bun", version: "1.4.0" }); + expect(fixture.find({ PATH: "/OPT/fixture/bin" })).toEqual({ path: "/opt/fixture/bin/bun", version: "1.4.2" }); expect(fixture.calls).toHaveLength(2); }); @@ -595,19 +616,19 @@ describe("real PATH Bun validation", () => { test("Windows does not interpret POSIX write mode bits", () => { const fixture = pathProbeFixture("win32"); fixture.io.stat = () => ({ isFile: () => true, mode: 0o777 }); - expect(fixture.find({ Path: "C:\\trusted" })?.version).toBe("1.4.0"); + expect(fixture.find({ Path: "C:\\trusted" })?.version).toBe("1.4.2"); }); test.each(["false", "1.4.1", "1.4.0\nnoise", ""])("rejects a mismatched identity response %j", identity => { const fixture = pathProbeFixture(); - fixture.io.spawnSync = (_path, args) => ({ status: 0, stdout: args[0] === "--version" ? "1.4.0" : identity }); + fixture.io.spawnSync = (_path, args) => ({ status: 0, stdout: args[0] === "--version" ? "1.4.2" : identity }); expect(fixture.find({ PATH: "/candidate" })).toBeNull(); }); test.each(["exit", "signal", "error", "overflow", "throw"])("rejects a failed %s identity probe", kind => { const fixture = pathProbeFixture(); fixture.io.spawnSync = (_path, args) => { - if (args[0] === "--version") return { status: 0, stdout: "1.4.0" }; + if (args[0] === "--version") return { status: 0, stdout: "1.4.2" }; if (kind === "throw") throw new Error("spawn failure"); if (kind === "exit") return { status: 1, stdout: "1.4.0" }; if (kind === "signal") return { status: null, signal: "SIGKILL", stdout: "1.4.0" }; @@ -622,7 +643,7 @@ describe("real PATH Bun validation", () => { fixture.io.spawnSync = (path, args, options) => { fixture.calls.push({ path, args, timeout: options.timeout, shell: options.shell, maxBuffer: options.maxBuffer }); fixture.advance(200); - return { status: 0, stdout: args[0] === "--version" || path === "/last/bun" ? "1.4.0" : "false" }; + return { status: 0, stdout: args[0] === "--version" || path === "/last/bun" ? "1.4.2" : "false" }; }; expect(fixture.find({ PATH: "/first:/last" })).toBeNull(); expect(fixture.calls.map(call => call.timeout)).toEqual([750, 550, 350, 150]); @@ -633,9 +654,9 @@ describe("real PATH Bun validation", () => { fixture.io.spawnSync = (path, args, options) => { fixture.calls.push({ path, args, timeout: options.timeout, shell: options.shell, maxBuffer: options.maxBuffer }); fixture.advance(100); - return { status: 0, stdout: "1.4.0" }; + return { status: 0, stdout: "1.4.2" }; }; - expect(fixture.find({ PATH: "/trusted" })?.version).toBe("1.4.0"); + expect(fixture.find({ PATH: "/trusted" })?.version).toBe("1.4.2"); expect(fixture.calls.map(call => call.timeout)).toEqual([750, 650]); }); @@ -651,7 +672,7 @@ describe("real PATH Bun validation", () => { const fixture = pathProbeFixture("win32"); fixture.io.spawnSync = (path, args, options) => { fixture.calls.push({ path, args, timeout: options.timeout, shell: options.shell, maxBuffer: options.maxBuffer }); - return { status: 0, stdout: path.startsWith("\\\\") ? "1.4.0" : "1.3.0" }; + return { status: 0, stdout: path.startsWith("\\\\") ? "1.4.2" : "1.3.0" }; }; expect(fixture.find({ Path: "C:\\first;c:\\FIRST;\\\\host\\share" })?.path).toBe("\\\\host\\share\\bun.exe"); expect(fixture.calls.map(call => call.path)).toEqual(["C:\\first\\bun.exe", "\\\\host\\share\\bun.exe", "\\\\host\\share\\bun.exe"]); @@ -661,7 +682,7 @@ describe("real PATH Bun validation", () => { const root = pathFixtureRoot("ocx-path-identity-"); try { const path = join(root, "bun"); - writeFileSync(path, '#!/bin/sh\nif [ "$1" = "--version" ]; then printf "1.4.0\\n"; else exit 1; fi\n#' + "x".repeat(REAL_BUN_MIN_BYTES)); + writeFileSync(path, '#!/bin/sh\nif [ "$1" = "--version" ]; then printf "1.4.2\\n"; else exit 1; fi\n#' + "x".repeat(REAL_BUN_MIN_BYTES)); chmodSync(path, 0o755); expect(findPathBun({ env: { PATH: root }, pinnedVersion: "1.4.2", deadlineMs: 2000 })).toBeNull(); } finally { removeTree(root); } @@ -671,7 +692,7 @@ describe("real PATH Bun validation", () => { const root = pathFixtureRoot("ocx-path-mode-"); try { const path = join(root, "bun"); - writeFileSync(path, '#!/bin/sh\nprintf "1.4.0\\n"\n#' + "x".repeat(REAL_BUN_MIN_BYTES)); + writeFileSync(path, '#!/bin/sh\nprintf "1.4.2\\n"\n#' + "x".repeat(REAL_BUN_MIN_BYTES)); chmodSync(path, 0o777); const find = () => findPathBun({ env: { PATH: root }, pinnedVersion: "1.4.2", deadlineMs: 2000 }); expect(find()).toBeNull(); @@ -750,7 +771,7 @@ describe("real PATH Bun validation", () => { const name = process.platform === "win32" ? "bun.exe" : "bun"; writeFileSync(join(first, name), "not bun"); if (process.platform === "win32") copyFileSync(process.execPath, join(last, name)); - else writeFileSync(join(last, name), '#!/bin/sh\ncase "$1" in --version|-e) printf "1.4.0\\n";; *) exit 1;; esac\n#' + "x".repeat(REAL_BUN_MIN_BYTES)); + else writeFileSync(join(last, name), '#!/bin/sh\ncase "$1" in --version|-e) printf "1.4.2\\n";; *) exit 1;; esac\n#' + "x".repeat(REAL_BUN_MIN_BYTES)); chmodSync(join(first, name), 0o755); chmodSync(join(last, name), 0o755); const env = { ...process.env, PATH: [first, last].join(process.platform === "win32" ? ";" : ":") }; // Windows keeps the inherited variable under its stored case (e.g. `Path`); From 852bf9d0254a77c1b4091cb6f031bb4163c0f283 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 26 Oct 2026 18:45:42 +0900 Subject: [PATCH 074/101] fix(responses): bound closing handshakes for non-reused upstream WebSockets (#7743) A private Codex WebSocket now gets a one-second graceful close window before the native socket is terminated; retained-pool disposal stays immediate. Carries #7681. Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- src/server/responses/codex-ws-session.ts | 42 ++- structure/transports/responses-wire-shapes.md | 16 +- .../codex-ws-handshake-validation.test.ts | 257 +++++++++++++++++- 3 files changed, 302 insertions(+), 13 deletions(-) diff --git a/src/server/responses/codex-ws-session.ts b/src/server/responses/codex-ws-session.ts index 87d8b81905d..36f7065792b 100644 --- a/src/server/responses/codex-ws-session.ts +++ b/src/server/responses/codex-ws-session.ts @@ -1,6 +1,43 @@ import { readCodexWsUpgradeHeaders } from "./codex-ws-metadata"; export const MAX_CODEX_WS_SESSION_EXCHANGES = 32; +export const CODEX_WS_SESSION_CLOSE_GRACE_MS = 1_000; + +/** A disposed lease must not leave a private socket waiting on an unresponsive peer. */ +function closeSessionSocket(socket: WebSocket & { terminate?: () => void }, immediate: boolean): void { + if (immediate) { + // Preserve the retained pool's immediate physical-capacity release. + try { socket.close(); } catch { /* already closing */ } + try { socket.terminate?.(); } catch { /* already closed */ } + return; + } + if (socket.readyState === 3) return; + if (typeof socket.terminate !== "function") { + // A close observer cannot force teardown without the optional native method. + try { socket.close(); } catch { /* best-effort graceful teardown */ } + return; + } + let timer: ReturnType | undefined; + const cleanup = (): void => { + if (timer !== undefined) clearTimeout(timer); + timer = undefined; + socket.removeEventListener("close", cleanup); + }; + const terminate = (): void => { + cleanup(); + if (socket.readyState !== 3) { + try { socket.terminate?.(); } catch { /* best-effort physical teardown */ } + } + }; + // Install before close(): older runtimes and test peers may close synchronously. + // Capture only the socket, not the disposed session or its former exchange owner. + socket.addEventListener("close", cleanup, { once: true }); + timer = setTimeout(terminate, CODEX_WS_SESSION_CLOSE_GRACE_MS); + timer.unref?.(); + try { socket.close(); } catch { terminate(); } + // close() may synchronously change the state narrowed by the earlier guard. + if ((socket.readyState as number) === 3) cleanup(); +} /** Owns one physical socket; request listeners belong to the exchange, not this object. */ export class CodexWsSession { @@ -69,10 +106,7 @@ export class CodexWsSession { try { owner?.(reason); } finally { this.busy = false; this.completedIds.clear(); - try { this.socket.close(); } catch { /* already closing */ } - if (this.retainable) { - try { (this.socket as WebSocket & { terminate?: () => void }).terminate?.(); } catch { /* already closed */ } - } + closeSessionSocket(this.socket, this.retainable); this.changed(); } } diff --git a/structure/transports/responses-wire-shapes.md b/structure/transports/responses-wire-shapes.md index 4bbe49be3e4..476f2267128 100644 --- a/structure/transports/responses-wire-shapes.md +++ b/structure/transports/responses-wire-shapes.md @@ -485,14 +485,14 @@ These are transport-fidelity guarantees, not a provider-billing guarantee. Every exchange also leaves a content-free stage record (`CodexWsStageRecord`, #4191): create-frame bytes (measured on failure only — the committed-success record keeps it null so the happy path never byte-counts a megabyte replay frame), send completion, numeric close code, elapsed, first-frame and first-response-event durations, frame counters, liveness ping/pong counts, pool reuse, the OCX/Bun versions, and (#6740) `terminalEvent` — the normalized `response.completed` / `response.failed` / `response.incomplete` / `error` frame that ended a plain exchange, observed before any synthetic tail — with `terminalErrorStatus`, the wrapper status of a terminal `error` frame. Both are null when no terminal frame arrived, on native-control exchanges (which can end on an acknowledgement), and on rows written before they existed; malformed persisted values read as null without dropping the stage. The exchange pins the record on the resolved Response (`markCodexWsStage`, the same marker seam as `markCodexWsResponse`); `handleResponses` adopts it onto the serving attempt, and usage.jsonl persists it per attempt behind a drop-guard normalizer, so hand-edited rows cannot inject strings into the DTO. Later snapshots update the same response-local record in place, so an attempt holding the committed reference observes final success or failure counters. Each exchange supplies a complete fresh snapshot; separate responses keep distinct records. On eager-relay cancel-drain expiry, upstream cancellation finalizes the transport snapshot before the cancellation hook writes the usage row; an actual terminal observed within the drain still wins over cancellation. The record never carries conversation text, headers, close-reason text, or account identifiers, and it is not a fallback-eligibility signal: nothing it says permits a resend. The one replacement an operator can grant after a socket dies is the resend gate's decision (see [ambiguous-resend gate](responses-failover.md#ambiguous-resend-gate)). -Eligible complete-input creates can retain a canonical upstream socket within -one selected account, credential, thread and turn. Model/tier and immutable -handshake headers and the selected outbound proxy must also match. Turn-state and turn-metadata headers are -projected into their same-name per-frame metadata slots; explicit body values win. -The pool retains at most 32 sockets, expires idle sockets after 30 seconds, and -retires a socket after five minutes or 32 successful exchanges (after active work -finishes). Cancellation, errors, idle unsolicited frames and shutdown dispose it. -A busy key uses a separate one-shot connection rather than interleaving requests. +Eligible complete-input creates can retain a canonical upstream socket within one selected account, credential, thread and turn. +Model/tier, immutable handshake headers and the selected outbound proxy must also match. Turn-state and turn-metadata headers are projected into their same-name per-frame metadata slots; explicit body values win. +The pool retains at most 32 sockets, expires idle sockets after 30 seconds, and retires a socket after five minutes or 32 successful exchanges (after active work finishes). +Cancellation, errors, idle unsolicited frames and shutdown dispose it. A busy key uses a separate one-shot connection rather than interleaving requests. +Private disposal requests graceful close, then schedules optional native `terminate()` after a one-second grace; successful retained release stays reusable. +That unrefed fallback holds only the socket and cleanup state, removes its close observer on timeout, and cancels both on early close. +Without native `terminate()`, disposal attempts `close()` without a fallback timer or observer; retained disposal requests close and optional terminate immediately. +`tests/responses/codex-ws-handshake-validation.test.ts` covers native stalled-peer teardown and optional-method cleanup. This is connection reuse, not native incremental-input synthesis: complete HTTP inputs are never trimmed and no previous response id is invented. Explicit diff --git a/tests/responses/codex-ws-handshake-validation.test.ts b/tests/responses/codex-ws-handshake-validation.test.ts index 5af7c3ef83b..31290af35e9 100644 --- a/tests/responses/codex-ws-handshake-validation.test.ts +++ b/tests/responses/codex-ws-handshake-validation.test.ts @@ -1,6 +1,7 @@ import { afterEach, expect, test } from "bun:test"; +import { watchdogMs } from "../helpers/ci-watchdog"; import { codexWsExchange } from "../../src/server/responses/codex-ws-exchange"; -import { CodexWsSession } from "../../src/server/responses/codex-ws-session"; +import { CODEX_WS_SESSION_CLOSE_GRACE_MS, CodexWsSession } from "../../src/server/responses/codex-ws-session"; import { CODEX_RESPONSES_HTTP_URL, prepareCodexWsRequest } from "../../src/server/responses/codex-ws-request"; import { CODEX_WS_METADATA_MAX_BYTES, CODEX_WS_METADATA_MAX_HEADERS, CODEX_WS_METADATA_MAX_VALUE_BYTES, @@ -123,3 +124,257 @@ test("non-canonical Responses transports keep their existing header policy", asy const response = await exchange(upgrade([flag, "true"]), "https://api.openai.com/v1/responses"); expect(response.headers.has(flag)).toBe(false); }); + +interface CloseTimer { callback: () => void; delay: number; unref: () => void } +class ClosingSocket extends EventTarget { + readyState = 0; + closeCalls = 0; + terminateCalls = 0; + closeMode: "stall" | "sync" | "throw" = "stall"; + terminateThrows = false; + readonly closeListeners = new Set(); + override addEventListener(type: string, listener: EventListenerOrEventListenerObject | null, options?: AddEventListenerOptions | boolean) { + super.addEventListener(type, listener, options); + if (type === "close" && listener) this.closeListeners.add(listener); + } + override removeEventListener(type: string, listener: EventListenerOrEventListenerObject | null, options?: EventListenerOptions | boolean) { + super.removeEventListener(type, listener, options); + if (type === "close" && listener) this.closeListeners.delete(listener); + } + open() { this.readyState = 1; this.dispatchEvent(new Event("open")); } + peerClose() { this.readyState = 3; this.dispatchEvent(new Event("close")); } + close() { + this.closeCalls++; + if (this.closeMode === "throw") throw new Error("fixture close failure"); + this.readyState = 2; + if (this.closeMode === "sync") this.peerClose(); + } + terminate?: () => void = () => { + this.terminateCalls++; + if (this.terminateThrows) throw new Error("fixture terminate failure"); + this.peerClose(); + }; +} + +/** Synchronous clock scope: no awaited work runs while global timers are replaced. */ +function withClosingSession(run: (fixture: { + session: CodexWsSession; socket: ClosingSocket; timers: Set; + expire(): void; unrefs(): number; changes(): number; +}) => void, retainable = false) { + const savedSocket = globalThis.WebSocket; + const savedSet = globalThis.setTimeout; + const savedClear = globalThis.clearTimeout; + const timers = new Set(); + let unrefs = 0, changes = 0; + globalThis.WebSocket = ClosingSocket as unknown as typeof WebSocket; + globalThis.setTimeout = ((callback: () => void, delay: number) => { + const timer = { callback, delay, unref: () => { unrefs++; } }; + timers.add(timer); + return timer; + }) as unknown as typeof setTimeout; + globalThis.clearTimeout = ((timer: CloseTimer) => { timers.delete(timer); }) as unknown as typeof clearTimeout; + try { + const session = new CodexWsSession("ws://127.0.0.1/fixture", {}, retainable, () => { changes++; }); + run({ session, socket: session.socket as unknown as ClosingSocket, timers, + unrefs: () => unrefs, changes: () => changes, + expire() { + for (const timer of [...timers]) { + timers.delete(timer); + timer.callback(); + } + }, + }); + } finally { + globalThis.setTimeout = savedSet; + globalThis.clearTimeout = savedClear; + globalThis.WebSocket = savedSocket; + } +} + +test("private closing handshake has one unrefed grace timer and one forced teardown", () => { + withClosingSession(({ session, socket, timers, expire, unrefs, changes }) => { + socket.open(); + expect(session.reserve()).toBe(true); + const reason = new Error("fixture cancellation"); + let observed: Error | undefined; + session.bindOwner(error => { observed = error; }); + session.dispose(reason); + expect(observed).toBe(reason); + expect(session.closed).toBe(true); + expect(session.busy).toBe(false); + expect(socket.closeCalls).toBe(1); + expect(socket.terminateCalls).toBe(0); + expect(timers.size).toBe(1); + expect([...timers][0]!.delay).toBe(CODEX_WS_SESSION_CLOSE_GRACE_MS); + expect(unrefs()).toBe(1); + session.dispose(); + expect(timers.size).toBe(1); + expire(); + expect(socket.terminateCalls).toBe(1); + expect(socket.readyState).toBe(3); + expect(timers.size).toBe(0); + expect(socket.closeListeners.size).toBe(0); + expect(changes()).toBe(1); + }); +}); + +test("private closing handshake cancels fallback on asynchronous and synchronous close", () => { + for (const mode of ["stall", "sync"] as const) withClosingSession(({ session, socket, timers, expire }) => { + socket.open(); socket.closeMode = mode; + session.dispose(); + if (mode === "stall") socket.peerClose(); + expect(timers.size).toBe(0); + expect(socket.closeListeners.size).toBe(0); + expire(); + expect(socket.terminateCalls).toBe(0); + }); +}); + +test("retained closing handshake keeps immediate disposal and successful reuse", () => { + withClosingSession(({ session, socket, timers }) => { + socket.open(); session.reserve(); session.release("fixture-completed"); + expect(session.hasCompleted("fixture-completed")).toBe(true); + expect(session.reserve()).toBe(true); + expect(socket.closeCalls).toBe(0); + expect(timers.size).toBe(0); + session.dispose(); + expect(socket.closeCalls).toBe(1); + expect(socket.terminateCalls).toBe(1); + expect(timers.size).toBe(0); + }, true); +}); + +test("private closing handshake contains close and terminate exceptions", () => { + withClosingSession(({ session, socket, timers, changes }) => { + socket.closeMode = "throw"; + session.dispose(); + expect(socket.terminateCalls).toBe(1); + expect(timers.size).toBe(0); + expect(socket.closeListeners.size).toBe(0); + expect(changes()).toBe(1); + }); + withClosingSession(({ session, socket, timers, expire }) => { + socket.open(); socket.terminateThrows = true; + session.dispose(); expire(); + expect(socket.terminateCalls).toBe(1); + expect(timers.size).toBe(0); + expect(socket.closeListeners.size).toBe(0); + }); +}); + +test("private closing handshake remains bounded when the exchange owner throws", () => { + withClosingSession(({ session, socket, expire, changes }) => { + socket.open(); session.reserve(); + session.bindOwner(() => { throw new Error("fixture owner failure"); }); + expect(() => session.dispose()).toThrow("fixture owner failure"); + expect(changes()).toBe(1); + expire(); + expect(socket.terminateCalls).toBe(1); + }); +}); + +test("private closing handshake covers completed releases, failed releases and connecting sockets", () => { + for (const completedId of ["fixture-completed", null]) withClosingSession(({ session, socket, expire }) => { + socket.open(); session.reserve(); session.release(completedId); expire(); + expect(socket.terminateCalls).toBe(1); + }); + withClosingSession(({ session, socket, expire }) => { + session.dispose(); expire(); + expect(socket.terminateCalls).toBe(1); + }); +}); + +test("private closing handshake creates no timer after a remote close", () => { + withClosingSession(({ session, socket, timers }) => { + socket.open(); socket.peerClose(); session.dispose(); + expect(socket.closeCalls).toBe(0); + expect(socket.terminateCalls).toBe(0); + expect(timers.size).toBe(0); + }); +}); + +test("private closing handshake adds no observer or timer without optional terminate", () => { + for (const mode of ["stall", "throw"] as const) withClosingSession(({ session, socket, timers, unrefs, changes }) => { + socket.open(); socket.terminate = undefined; socket.closeMode = mode; + session.dispose(); + expect(socket.closeCalls).toBe(1); + expect(timers.size).toBe(0); + expect(socket.closeListeners.size).toBe(0); + expect(unrefs()).toBe(0); + expect(changes()).toBe(1); + }); +}); + +test.each(["dispose", "release"] as const)("private %s closes a real peer that withholds its close reply", async mode => { + // A raw peer accepts the upgrade and reads Close, but deliberately never answers it. + // Keep the native client's close and terminate operations intact on both Bun versions. + const keys = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"]; + const saved = Object.fromEntries(keys.map(key => [key, process.env[key]])); + for (const key of keys) delete process.env[key]; + let listener: ReturnType | undefined; + let session: CodexWsSession | undefined; + let timer: ReturnType | undefined; + let resolvePeerClosed!: () => void; + const peerClosed = new Promise(resolve => { resolvePeerClosed = resolve; }); + let headers = Buffer.alloc(0), closeFrame = Buffer.alloc(0), upgraded = false; + try { + listener = Bun.listen({ + hostname: "127.0.0.1", port: 0, + socket: { + data(socket, data) { + if (!upgraded) { + headers = Buffer.concat([headers, data]); + const boundary = headers.indexOf("\r\n\r\n"); + if (boundary < 0) return; + const key = /^Sec-WebSocket-Key:\s*(.+)\r?$/im.exec(headers.subarray(0, boundary).toString())?.[1]?.trim(); + if (!key) { socket.end("HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n"); return; } + const accept = new Bun.CryptoHasher("sha1").update(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest("base64"); + upgraded = true; + socket.write("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n" + + `Sec-WebSocket-Accept: ${accept}\r\n\r\n`); + data = headers.subarray(boundary + 4); + } + closeFrame = Buffer.concat([closeFrame, data]); + }, + end(socket) { socket.end(); resolvePeerClosed(); }, + close() { resolvePeerClosed(); }, + }, + }); + session = new CodexWsSession(`ws://127.0.0.1:${listener.port}`, {}); + const socket = session.socket; + await new Promise((resolve, reject) => { + const cleanup = () => { + clearTimeout(timer); timer = undefined; + socket.removeEventListener("open", onOpen); + socket.removeEventListener("error", onError); + }; + const onOpen = () => { cleanup(); resolve(); }; + const onError = () => { cleanup(); reject(new Error("fixture WebSocket failed to open")); }; + socket.addEventListener("open", onOpen, { once: true }); + socket.addEventListener("error", onError, { once: true }); + timer = setTimeout(onError, watchdogMs(3000)); + }); + expect(session.reserve()).toBe(true); + const deadline = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("disposed private socket left its peer open")), watchdogMs(3000)); + }); + if (mode === "release") session.release("fixture-completed"); + else session.dispose(); + expect(session.closed).toBe(true); + expect(session.busy).toBe(false); + expect(session.reserve()).toBe(false); + await Promise.race([peerClosed, deadline]); + expect(closeFrame.length).toBeGreaterThanOrEqual(2); + expect(closeFrame[0]! & 0x0f).toBe(0x08); + expect(socket.readyState).toBe(3); + } finally { + clearTimeout(timer); + session?.dispose(); + try { (session?.socket as (WebSocket & { terminate?: () => void }) | undefined)?.terminate?.(); } catch { /* fixture teardown */ } + listener?.stop(true); + for (const key of keys) { + if (saved[key] === undefined) delete process.env[key]; + else process.env[key] = saved[key]; + } + } +}, watchdogMs(5000)); From bada676a64193dff6147a08ebda2796d6bb08a11 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Mon, 26 Oct 2026 10:10:32 +0000 Subject: [PATCH 075/101] fix(desktop): bind usage trials to fresh package proof (#7709) * fix(desktop): bind short trial consent to fresh package proof * fix(desktop): reject reparse ancestry in trial identity proof --- .../desktop-compatibility/installed-build.ts | 100 ++++++++++++++-- src/codex/desktop-compatibility/runtime.ts | 34 +++++- .../desktop-compatibility/usage-controller.ts | 2 + structure/clients/codex-desktop.md | 18 ++- .../desktop-compatibility-build-probe.test.ts | 110 +++++++++++++++++- .../desktop-compatibility-runtime.test.ts | 87 +++++++++++++- tests/helpers/desktop-package-observation.ts | 14 +++ 7 files changed, 337 insertions(+), 28 deletions(-) create mode 100644 tests/helpers/desktop-package-observation.ts diff --git a/src/codex/desktop-compatibility/installed-build.ts b/src/codex/desktop-compatibility/installed-build.ts index ba45623d608..f7cbb4052bd 100644 --- a/src/codex/desktop-compatibility/installed-build.ts +++ b/src/codex/desktop-compatibility/installed-build.ts @@ -1,13 +1,61 @@ import { execFile } from "node:child_process"; import { win32 } from "node:path"; +import { createHash } from "node:crypto"; import { isTestHomeGuardArmed } from "../../lib/test-home-guard"; import { resolveTrustedWindowsPowerShellExe } from "../../lib/windows-elevation"; -import { parseWindowsDesktopPackage, WINDOWS_PACKAGE_DISCOVERY_SCRIPT } from "../desktop-app/windows"; import type { DesktopAppInstall } from "../desktop-app/types"; export const DESKTOP_COMPATIBILITY_ASSESSED_VERSION = "26.924.2738.0"; export const DESKTOP_COMPATIBILITY_ASSESSED_FAMILY = "OpenAI.Codex_2p2nqsd0c76g0"; +/** Internal observation only; never returned by management status. */ +export interface DesktopPackageObservation extends DesktopAppInstall { + proof?: { packageFullName: string; publisherId: string; publisher: string; manifestName: string; + manifestPublisher: string; manifestVersion: string; appId: string; executable: string; + canonicalExecutable: string; manifestDigest: string; executableIdentity: string; ancestryIdentity: string }; +} + +/** Shared only with owned-path fixtures. Metadata walk, not handle-bound final-path attestation. */ +export const DESKTOP_PACKAGE_PATH_GUARD_SCRIPT = [ + "function Assert-DesktopPackagePath([string]$Path){", + String.raw`if($Path.Length -gt 8192 -or $Path -notmatch '^[A-Za-z]:\\' -or [IO.Path]::GetFullPath($Path) -ine $Path){throw 'Noncanonical package path'}`, + "$volume=[IO.Path]::GetPathRoot($Path); $current=$volume; $parts=@($Path.Substring($volume.Length).Split('\\')|Where-Object {$_ -ne ''})", + "if($parts.Count -gt 128){throw 'Package ancestry too deep'}; $seen=[Collections.Generic.List[string]]::new()", + "for($i=-1;$i -lt $parts.Count;$i++){", + "if($i -ge 0){$current=Join-Path $current $parts[$i]}; $item=Get-Item -LiteralPath $current -Force", + "if(($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -or ($i -lt $parts.Count-1 -and -not $item.PSIsContainer)){throw 'Reparse package ancestry'}", + "$seen.Add($item.FullName.ToLowerInvariant()+':'+$item.CreationTimeUtc.Ticks)", + "}; return ($seen -join '|')", + "}", +].join("; "); + +// Compatibility-only read: do not alter general discovery or execute/activate the application. +export const DESKTOP_PACKAGE_PROOF_SCRIPT = [ + "$ErrorActionPreference='Stop'; Import-Module Appx -ErrorAction Stop", + DESKTOP_PACKAGE_PATH_GUARD_SCRIPT, + "$packages=@(Get-AppxPackage -Name OpenAI.Codex)", + "if($packages.Count -ne 1){throw 'Package not unique'}; $p=$packages[0]", + // Walk from volume root down, rejecting junctions before reading the manifest/executable. + "$exePath=Join-Path $p.InstallLocation 'app\\ChatGPT.exe'; $manifestPath=Join-Path $p.InstallLocation 'AppxManifest.xml'", + "$exeChain=Assert-DesktopPackagePath $exePath; $manifestChain=Assert-DesktopPackagePath $manifestPath", + "$root=Get-Item -LiteralPath $p.InstallLocation; $app=Get-Item -LiteralPath (Join-Path $root.FullName 'app')", + "$exe=Get-Item -LiteralPath (Join-Path $app.FullName 'ChatGPT.exe')", + "foreach($item in @($root,$app,$exe)){if($item.Attributes -band [IO.FileAttributes]::ReparsePoint){throw 'Reparse identity'}}", + "if(-not $root.PSIsContainer -or -not $app.PSIsContainer -or $exe.PSIsContainer){throw 'Wrong file kind'}", + "$manifest=Get-AppxPackageManifest -Package $p.PackageFullName", + "$entry=@($manifest.Package.Applications.Application|Where-Object {$_.Id -eq 'App'})", + "if($entry.Count -ne 1){throw 'App not unique'}; $entry=$entry[0]", + "$xml=[Text.Encoding]::UTF8.GetBytes($manifest.OuterXml); if($xml.Length -gt 1048576){throw 'Manifest too large'}", + "$sha=[Security.Cryptography.SHA256]::Create(); try{$digest=([BitConverter]::ToString($sha.ComputeHash($xml))).Replace('-','').ToLowerInvariant()}finally{$sha.Dispose()}", + // Revalidate the whole chain before further file reads and immediately before publication. + "if((Assert-DesktopPackagePath $exePath) -cne $exeChain -or (Assert-DesktopPackagePath $manifestPath) -cne $manifestChain){throw 'Package ancestry changed'}", + "$again=@(Get-AppxPackage -Name OpenAI.Codex); $exeAgain=Get-Item -LiteralPath $exe.FullName", + "if($again.Count -ne 1 -or $again[0].PackageFullName -cne $p.PackageFullName -or $again[0].Publisher -cne $p.Publisher -or $again[0].InstallLocation -ine $p.InstallLocation -or ($exeAgain.Attributes -band [IO.FileAttributes]::ReparsePoint) -or $exeAgain.Length -ne $exe.Length -or $exeAgain.CreationTimeUtc.Ticks -ne $exe.CreationTimeUtc.Ticks -or $exeAgain.LastWriteTimeUtc.Ticks -ne $exe.LastWriteTimeUtc.Ticks){throw 'Package changed during proof'}", + "if((Assert-DesktopPackagePath $exePath) -cne $exeChain -or (Assert-DesktopPackagePath $manifestPath) -cne $manifestChain){throw 'Package ancestry changed'}", + "$sha=[Security.Cryptography.SHA256]::Create(); try{$ancestry=([BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($exeChain+'|'+$manifestChain)))).Replace('-','').ToLowerInvariant()}finally{$sha.Dispose()}", + "[pscustomobject]@{id=$p.PackageFamilyName;root=$root.FullName;relaunch=$p.PackageFamilyName+'!App';proof=@{packageFullName=$p.PackageFullName;publisherId=$p.PublisherId;publisher=$p.Publisher;manifestName=[string]$manifest.Package.Identity.Name;manifestPublisher=[string]$manifest.Package.Identity.Publisher;manifestVersion=[string]$manifest.Package.Identity.Version;appId=[string]$entry.Id;executable=[string]$entry.Executable;canonicalExecutable=$exe.FullName;manifestDigest=$digest;executableIdentity=([string]$exe.Length+':'+$exe.CreationTimeUtc.Ticks+':'+$exe.LastWriteTimeUtc.Ticks);ancestryIdentity=$ancestry}}|ConvertTo-Json -Depth 3 -Compress", +].join("; "); + /** Last completed probe outcome. Only public package facts: never paths, keys or account data. */ export interface DesktopBuildAssessment { assessedVersion: string; @@ -16,24 +64,47 @@ export interface DesktopBuildAssessment { completedAt: number; } -function assessInstalledPackage(install: DesktopAppInstall): { observedVersion: string | null; verified: boolean } { +function assessInstalledPackage(install: DesktopPackageObservation): { observedVersion: string | null; verified: boolean; identity: string | null } { const name = win32.basename(install.root); const parts = /^OpenAI\.Codex_(\d+\.\d+\.\d+\.\d+)_(x64|arm64|x86|neutral)_([A-Za-z0-9.-]*)_([A-Za-z0-9]+)$/.exec(name); const identified = install.id === DESKTOP_COMPATIBILITY_ASSESSED_FAMILY && install.relaunch === `${install.id}!App` && !!parts && `OpenAI.Codex_${parts[4]}` === install.id; const observedVersion = identified ? parts![1] : null; - return { observedVersion, verified: identified && observedVersion === DESKTOP_COMPATIBILITY_ASSESSED_VERSION }; + const proof = install.proof; + const verified = identified && observedVersion === DESKTOP_COMPATIBILITY_ASSESSED_VERSION && !!proof + && /^[A-Za-z]:\\/.test(install.root) && win32.normalize(install.root) === install.root + && proof.packageFullName === name && proof.publisherId === "2p2nqsd0c76g0" + && typeof proof.publisher === "string" && proof.publisher.length > 0 && proof.publisher.length <= 1024 + && proof.manifestName === "OpenAI.Codex" && proof.manifestPublisher === proof.publisher + && proof.manifestVersion === observedVersion && proof.appId === "App" + && typeof proof.executable === "string" && proof.executable.replaceAll("/", "\\").toLowerCase() === "app\\chatgpt.exe" + && proof.canonicalExecutable === win32.join(install.root, "app", "ChatGPT.exe") + && /^[a-f0-9]{64}$/.test(proof.manifestDigest) && /^[a-f0-9]{64}$/.test(proof.ancestryIdentity) + && /^\d{1,16}:\d{1,20}:\d{1,20}$/.test(proof.executableIdentity); + const identity = verified && proof ? createHash("sha256").update(JSON.stringify([install.id, install.root, install.relaunch, + proof.packageFullName, proof.publisherId, proof.publisher, proof.manifestName, proof.manifestPublisher, + proof.manifestVersion, proof.appId, proof.executable, proof.canonicalExecutable, proof.manifestDigest, + proof.executableIdentity, proof.ancestryIdentity])).digest("hex") : null; + return { observedVersion, verified, identity }; } /** Resolve only after the exact asynchronous child has closed, including abort/timeout. */ -function queryInstalledPackage(signal: AbortSignal): Promise { +function queryInstalledPackage(signal: AbortSignal): Promise { if (process.platform !== "win32" || isTestHomeGuardArmed() || signal.aborted) return Promise.resolve(null); return new Promise(resolve => { - let completed = false, closed = false, result: DesktopAppInstall | null = null; + let completed = false, closed = false, result: DesktopPackageObservation | null = null; const finish = () => { if (completed && closed) { signal.removeEventListener("abort", abort); resolve(result); } }; - const child = execFile(resolveTrustedWindowsPowerShellExe(), ["-NoProfile", "-NonInteractive", "-Command", WINDOWS_PACKAGE_DISCOVERY_SCRIPT], { + const child = execFile(resolveTrustedWindowsPowerShellExe(), ["-NoProfile", "-NonInteractive", "-Command", DESKTOP_PACKAGE_PROOF_SCRIPT], { timeout: 10000, maxBuffer: 65536, encoding: "utf8", windowsHide: true, - }, (error, stdout) => { result = error ? null : parseWindowsDesktopPackage(stdout); completed = true; finish(); }); + }, (error, stdout) => { + try { + const value = error ? null : JSON.parse(stdout); + if (value && typeof value === "object" && !Array.isArray(value) && typeof value.id === "string" + && typeof value.root === "string" && typeof value.relaunch === "string" + && value.proof && typeof value.proof === "object" && !Array.isArray(value.proof)) result = value; + } catch { /* An unverifiable package never inherits a completed success. */ } + completed = true; finish(); + }); const abort = () => { try { child.kill(); } catch { /* Keep ownership until execFile reports close. */ } }; child.once("close", () => { closed = true; finish(); }); signal.addEventListener("abort", abort, { once: true }); @@ -42,12 +113,15 @@ function queryInstalledPackage(signal: AbortSignal): Promise Promise = queryInstalledPackage) { +export function createInstalledBuildProbe(query: (signal: AbortSignal) => Promise = queryInstalledPackage) { let pending: Promise | null = null, controller: AbortController | null = null, closed = false; let lastResult: DesktopBuildAssessment | null = null; - const record = (install: DesktopAppInstall | null) => { - const outcome = install ? assessInstalledPackage(install) : { observedVersion: null, verified: false }; - lastResult = { assessedVersion: DESKTOP_COMPATIBILITY_ASSESSED_VERSION, ...outcome, completedAt: Date.now() }; + let identity: string | null = null, revision = 0; + const record = (install: DesktopPackageObservation | null) => { + const outcome = install ? assessInstalledPackage(install) : { observedVersion: null, verified: false, identity: null }; + if (outcome.identity !== identity) { identity = outcome.identity; revision++; } + lastResult = { assessedVersion: DESKTOP_COMPATIBILITY_ASSESSED_VERSION, observedVersion: outcome.observedVersion, + verified: outcome.verified, completedAt: Date.now() }; return outcome.verified; }; return { @@ -63,7 +137,9 @@ export function createInstalledBuildProbe(query: (signal: AbortSignal) => Promis }, /** Last completed check only: an aborted or in-flight query never rewrites it. */ lastResult(): DesktopBuildAssessment | null { return lastResult ? { ...lastResult } : null; }, - async close(): Promise { closed = true; controller?.abort(); await pending; }, + /** Private consent fence: a completed A->B->A proof is distinct from unchanged A. */ + binding() { return { identity, revision }; }, + async close(): Promise { closed = true; identity = null; revision++; controller?.abort(); await pending; }, }; } diff --git a/src/codex/desktop-compatibility/runtime.ts b/src/codex/desktop-compatibility/runtime.ts index 074f17609c3..16bd52dd87a 100644 --- a/src/codex/desktop-compatibility/runtime.ts +++ b/src/codex/desktop-compatibility/runtime.ts @@ -39,16 +39,34 @@ export function createDesktopCompatibilityRuntime(io: DesktopRuntimeIo = {}) { const platform = io.platform ?? process.platform; let buildProbe: ReturnType | undefined; let previousBuildAssessment: DesktopBuildAssessment | null = null; + let boundBuild: { probe: ReturnType; revision: number } | null = null; + let invalidateTrial: (() => void) | undefined; + let contextEpoch = 0; const buildSupported = io.buildSupported ?? (() => buildProbe?.check() ?? Promise.resolve(false)); let routingSupported = io.routingSupported ?? (() => false); let contextFailure: "build_unverified" | "native_routing_unverified" | null = null; const contextValid = async () => { + const epoch = contextEpoch, probe = buildProbe; try { - if (!routingSupported()) { contextFailure = "native_routing_unverified"; return false; } + if (!routingSupported()) { contextFailure = "native_routing_unverified"; invalidateTrial?.(); return false; } const supported = await buildSupported(); + // An old probe completion must not invalidate a replacement runtime's consent. + if (epoch !== contextEpoch || probe !== buildProbe) return false; + if (!io.buildSupported && probe) { + const binding = probe.binding(); + const changed = boundBuild?.probe === probe && boundBuild.revision !== binding.revision; + boundBuild = { probe, revision: binding.revision }; + if (!supported || !binding.identity || changed) { + contextFailure = "build_unverified"; invalidateTrial?.(); return false; + } + } contextFailure = !routingSupported() ? "native_routing_unverified" : !supported ? "build_unverified" : null; } - catch { contextFailure = "build_unverified"; } + catch { + if (epoch !== contextEpoch || probe !== buildProbe) return false; + contextFailure = "build_unverified"; + } + if (contextFailure) invalidateTrial?.(); return contextFailure === null; }; let phase: "off" | "starting" | "running" | "stopping" | "cleanup-required" = "off"; @@ -63,14 +81,17 @@ export function createDesktopCompatibilityRuntime(io: DesktopRuntimeIo = {}) { if (phase === "starting") throw new Error("desktop_compatibility_busy"); if (closing) { await closing; return status(); } if (!pendingCleanup) return status(); + contextEpoch++; invalidateTrial?.(); phase = "stopping"; - closing = pendingCleanup().then(() => { owned = null; pendingCleanup = null; phase = "off"; releaseOwner?.(); releaseOwner = null; }) + closing = pendingCleanup().then(() => { owned = null; pendingCleanup = null; invalidateTrial = undefined; boundBuild = null; + phase = "off"; releaseOwner?.(); releaseOwner = null; }) .catch(error => { phase = "cleanup-required"; throw error; }).finally(() => { closing = null; }); await closing; return status(); } async function start() { if (phase !== "off") throw new Error("desktop_compatibility_busy"); if (platform !== "win32") throw new Error("desktop_compatibility_unsupported"); + contextEpoch++; boundBuild = null; invalidateTrial = undefined; if (io.testOnlyBuildProbe && (!io.testOnly || io.buildSupported)) throw new Error("desktop_compatibility_test_environment"); if ((isTestHomeGuardArmed() || io.testOnlyBuildProbe) && !(io.testOnly && io.identity && io.loadAuthority && io.trust && (io.buildSupported || io.testOnlyBuildProbe) && io.connectionStore && io.routingSupported)) throw new Error("desktop_compatibility_test_environment"); if (didRunOptionalShutdownHooks()) throw new Error("desktop_compatibility_stopping"); @@ -114,6 +135,8 @@ export function createDesktopCompatibilityRuntime(io: DesktopRuntimeIo = {}) { if (!account) throw new Error("desktop_compatibility_native_identity_unverified"); if (didRunOptionalShutdownHooks()) throw new Error("desktop_compatibility_stopping"); controller = new UsageRelayController(account, identity.readCurrentIdentity, identity.verifyFreshIdentity, Date.now, deadline, 180000, contextValid); + const trialController = controller; + invalidateTrial = () => trialController.invalidateContext(); relay = await startDesktopRelay({ leaf: issueServerLeaf(authority.authority, authority.commonName, ["chatgpt.com"]), fetchImpl: createUsageControlledFetch(controller, io.upstreamFetch ?? desktopOutboundFetch as typeof fetch) }); try { proxy = await startConnectProxy(previousConnection?.connectPort ?? 0, { interceptPort: relay.port, interceptHosts: ["chatgpt.com"], allowedTargets: ["chatgpt.com:443"] }); } @@ -150,7 +173,8 @@ export function createDesktopCompatibilityRuntime(io: DesktopRuntimeIo = {}) { detach = registerOptionalShutdownHook("codex-desktop-compatibility", () => { void stop().catch(() => {}); }); return status(); } catch (error) { - try { await cleanup(); pendingCleanup = null; phase = "off"; releaseOwner?.(); releaseOwner = null; } + try { await cleanup(); pendingCleanup = null; invalidateTrial = undefined; boundBuild = null; + phase = "off"; releaseOwner?.(); releaseOwner = null; } catch { phase = "cleanup-required"; throw new Error("desktop_compatibility_cleanup_incomplete"); } throw error; } @@ -160,8 +184,8 @@ export function createDesktopCompatibilityRuntime(io: DesktopRuntimeIo = {}) { if (!current || phase !== "running") throw new Error("desktop_compatibility_not_running"); const generation = current.controller.snapshot().generation, valid = await contextValid(); if (owned !== current || phase !== "running") throw new Error("desktop_compatibility_not_running"); - if (generation !== current.controller.snapshot().generation) return { accepted: false, reason: "superseded", ...current.controller.snapshot() }; if (!valid) { await current.controller.observeOnly(); throw new Error(`desktop_compatibility_${contextFailure}`); } + if (generation !== current.controller.snapshot().generation) return { accepted: false, reason: "superseded", ...current.controller.snapshot() }; const result = await current.controller.activate({ scope: "account-ui-compatibility", accountWideConsent }); if (owned !== current || phase !== "running") { await current.controller.observeOnly(); return { ...result, accepted: false, reason: "runtime-stopped" }; } return result; diff --git a/src/codex/desktop-compatibility/usage-controller.ts b/src/codex/desktop-compatibility/usage-controller.ts index ee01c04caf4..bc6d43e7551 100644 --- a/src/codex/desktop-compatibility/usage-controller.ts +++ b/src/codex/desktop-compatibility/usage-controller.ts @@ -46,6 +46,8 @@ export class UsageRelayController { return this.activation.activate(options); } observeOnly() { return this.activation.observeOnly(); } + /** Internal package-proof revocation; synchronous so pending output loses its generation first. */ + invalidateContext() { this.activation.invalidateIdentity(); } expireIfNeeded() { return this.activation.expireIfNeeded(); } registerStream(exchange: Exchange, close: () => Promise): Registration | null { if (exchange.status !== 200) return null; diff --git a/structure/clients/codex-desktop.md b/structure/clients/codex-desktop.md index 1471016161a..cb11a2005d9 100644 --- a/structure/clients/codex-desktop.md +++ b/structure/clients/codex-desktop.md @@ -183,13 +183,27 @@ table do not invalidate the routing snapshot. Desktop refreshes that tool-transp after launch; all other native fields, including unknown ones and selected profiles, remain bound. It does not claim knowledge of project-local overrides or a conversation's selected model. Each record that would be corrected checks routing and the assessed installed build asynchronously, -then rechecks account identity and trial generation before emitting it. `installed-build.ts` shares -the Windows adapter's discovery parser, coalesces concurrent probes without caching a positive +then rechecks account identity and trial generation before emitting it. `installed-build.ts` uses a +compatibility-only current-user package query that reads the registered manifest's actual App and +fixed ChatGPT.exe entry in the installed package's app directory, binds its publisher/name/version to the exact official family, +and refuses missing/reparse executable facts. It coalesces concurrent probes without caching a positive result across requests, and aborts/reaps its bounded child before shutdown completes. Observe and unchanged responses do not query Windows. Background refresh runs once a minute in Observe, every ten seconds during Apply, and stops probing after the safety deadline. Failed checks disarm Apply. Native update/routing failures are public diagnostic codes; originals continue to relay and no app/config repair is automatic. +The package proof's manifest digest and canonical executable metadata stay private. An observed +identity change or failed proof invalidates trial generation, prior exhaustion and outputs before +another correction; a supported-version A-to-B-to-A transition cannot revive old consent. Later +success requires a new original exhaustion and explicit account-wide consent with the unchanged +maximum three-minute trial. Stop/start replaces the observation context, and old probe completions +cannot affect its replacement. Identical observed registration/file metadata cannot detect every +same-version reinstall; this is not continuous file/process or signature attestation. +Every volume-root-to-file ancestor is checked for reparse points before manifest/file consumption +and rechecked before proof publication. FullName is lexical and is not a held-handle canonical-path +proof; the bounded before/after metadata walk does not exclude a transient replace-and-restore +between observations. Such unobserved replacement remains outside this observation contract. The launcher, +exact version allowlist, GET/SSE surface and default-off behavior remain unchanged. Each completed check also publishes a `buildAssessment` record on runtime status holding only `assessedVersion`, `observedVersion`, `verified` and `completedAt`. A status read never starts a probe, a runtime without a real probe omits the field, and an aborted or in-flight check never diff --git a/tests/clients/desktop-compatibility-build-probe.test.ts b/tests/clients/desktop-compatibility-build-probe.test.ts index e568e28b6d3..112c66ec3ab 100644 --- a/tests/clients/desktop-compatibility-build-probe.test.ts +++ b/tests/clients/desktop-compatibility-build-probe.test.ts @@ -1,9 +1,111 @@ import { expect, test } from "bun:test"; -import { createInstalledBuildProbe, DESKTOP_COMPATIBILITY_ASSESSED_VERSION, DESKTOP_COMPATIBILITY_ASSESSED_FAMILY } from "../../src/codex/desktop-compatibility/installed-build"; -import type { DesktopAppInstall } from "../../src/codex/desktop-app/types"; +import { createInstalledBuildProbe, DESKTOP_COMPATIBILITY_ASSESSED_VERSION, DESKTOP_PACKAGE_PROOF_SCRIPT, DESKTOP_PACKAGE_PATH_GUARD_SCRIPT } from "../../src/codex/desktop-compatibility/installed-build"; +import { lstatSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; +import { join, parse } from "node:path"; +import { spawnTestChild } from "../helpers/spawn-test-child"; +import { resolveTrustedWindowsPowerShellExe } from "../../src/lib/windows-elevation"; +import type { DesktopPackageObservation as DesktopAppInstall } from "../../src/codex/desktop-compatibility/installed-build"; +import { desktopPackageObservation } from "../helpers/desktop-package-observation"; -const installed = (version = DESKTOP_COMPATIBILITY_ASSESSED_VERSION): DesktopAppInstall => ({ - id: DESKTOP_COMPATIBILITY_ASSESSED_FAMILY, root: `C:\\Program Files\\WindowsApps\\OpenAI.Codex_${version}_x64__2p2nqsd0c76g0`, relaunch: `${DESKTOP_COMPATIBILITY_ASSESSED_FAMILY}!App`, +const installed = desktopPackageObservation; + +test.skipIf(process.platform !== "win32")("owned ancestor junctions and persistent retargets fail before proof publication", async () => { + // Ordinary volume-root fixture: never use a registered package or the installed application. + const root = mkdtempSync(join(parse(process.cwd()).root, "ocx-package-ancestry-")); + const first = join(root, "first"), second = join(root, "second"), alias = join(root, "alias"); + let terminal = true; + const quote = (value: string) => "'" + value.replaceAll("'", "''") + "'"; + try { + for (const directory of [first, second]) { + mkdirSync(join(directory, "package", "app"), { recursive: true }); + writeFileSync(join(directory, "package", "app", "ChatGPT.exe"), "fixture, not executable"); + writeFileSync(join(directory, "package", "AppxManifest.xml"), "fixture, not Appx"); + } + symlinkSync(first, alias, "junction"); + const script = ["$ErrorActionPreference='Stop'", DESKTOP_PACKAGE_PATH_GUARD_SCRIPT, + `$alias=${quote(alias)}; $first=${quote(first)}; $second=${quote(second)}`, + "$leaf=Join-Path $alias 'package\\app\\ChatGPT.exe'", + // Leaf attributes alone cannot reveal the ancestor junction. + "if((Get-Item -LiteralPath $leaf).Attributes -band [IO.FileAttributes]::ReparsePoint){throw 'Fixture leaf is not ordinary'}", + "function Refuses-BeforeRead([string]$File){$refused=$false; try{$null=Assert-DesktopPackagePath $File}catch{$refused=$true}; if(-not $refused){throw 'Guard admitted redirected ancestry'}; return $refused}", + "$initial=Refuses-BeforeRead $leaf", + "[IO.Directory]::Delete($alias); $null=New-Item -ItemType Junction -Path $alias -Target $second", + "$retarget=Refuses-BeforeRead $leaf", + "$ordinary=Join-Path $first 'package\\app\\ChatGPT.exe'; $before=Assert-DesktopPackagePath $ordinary", + "$null=Move-Item -LiteralPath $first -Destination ($first+'-saved'); $null=New-Item -ItemType Junction -Path $first -Target $second", + // The same revalidation used before publishing production proof must reject this change. + "$publication=Refuses-BeforeRead $ordinary", + "[pscustomobject]@{initial=$initial;retarget=$retarget;publication=$publication;ordinary=($before.Length -gt 0)}|ConvertTo-Json -Compress", + ].join("; "); + terminal = false; + const result = await spawnTestChild([resolveTrustedWindowsPowerShellExe(), "-NoProfile", "-NonInteractive", "-Command", script], + { cwd: root, deadlineMs: 10_000 }); + terminal = result.reaped && result.pipesClosed; + expect(result).toMatchObject({ exitCode: 0, timedOut: false, reaped: true, pipesClosed: true }); + expect(JSON.parse(result.stdout)).toEqual({ initial: true, retarget: true, publication: true, ordinary: true }); + } finally { + // An unreaped child retains its fixture; never recursively follow the owned junctions. + if (terminal) { + for (const path of [alias, first]) { + try { if (lstatSync(path).isSymbolicLink()) unlinkSync(path); } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + rmSync(root, { recursive: true }); + } + } +}, 20_000); + +test("manifest and canonical executable facts are required without admitting another version", async () => { + const good = installed(); + for (const proof of [undefined, + { ...good.proof!, manifestName: "Foreign.Codex" }, { ...good.proof!, manifestPublisher: "CN=Foreign" }, + { ...good.proof!, appId: "Other" }, { ...good.proof!, executable: "other\\ChatGPT.exe" }, + { ...good.proof!, canonicalExecutable: good.root + "-foreign\\app\\ChatGPT.exe" }, + { ...good.proof!, packageFullName: good.proof!.packageFullName + "_extra" }, + { ...good.proof!, manifestDigest: "unverified" }, { ...good.proof!, ancestryIdentity: "" }, + { ...good.proof!, executableIdentity: "" }]) { + const probe = createInstalledBuildProbe(async () => ({ ...good, proof })); + expect(await probe.check()).toBe(false); expect(probe.binding().identity).toBeNull(); await probe.close(); + } + const newer = createInstalledBuildProbe(async () => installed("26.1007.9446.0")); + expect(await newer.check()).toBe(false); await newer.close(); + expect(DESKTOP_PACKAGE_PROOF_SCRIPT).toContain("Get-AppxPackageManifest"); + expect(DESKTOP_PACKAGE_PROOF_SCRIPT).toContain("ChatGPT.exe"); + expect(DESKTOP_PACKAGE_PROOF_SCRIPT).not.toContain("Activate"); +}); + +test("same-version package proof changes and failure advance a private ABA fence", async () => { + let value: DesktopAppInstall | null = installed(); + const probe = createInstalledBuildProbe(async () => value); + expect(await probe.check()).toBe(true); const first = probe.binding(); + expect(await probe.check()).toBe(true); expect(probe.binding()).toEqual(first); + value = { ...installed(), proof: { ...installed().proof!, manifestDigest: "b".repeat(64) } }; + expect(await probe.check()).toBe(true); expect(probe.binding().revision).toBeGreaterThan(first.revision); + value = installed(); expect(await probe.check()).toBe(true); + expect(probe.binding().identity).toBe(first.identity); expect(probe.binding().revision).toBeGreaterThan(first.revision); + value = null; expect(await probe.check()).toBe(false); expect(probe.binding().identity).toBeNull(); + const failedRevision = probe.binding().revision; + value = installed(); expect(await probe.check()).toBe(true); expect(probe.binding().revision).toBeGreaterThan(failedRevision); + expect(JSON.stringify(probe.lastResult())).not.toContain(first.identity!); + expect(JSON.stringify(probe.lastResult())).not.toContain("ChatGPT.exe"); + await probe.close(); expect(probe.binding().identity).toBeNull(); +}); + +test("identical observed metadata cannot claim to detect a same-version reinstall", async () => { + const probe = createInstalledBuildProbe(async () => structuredClone(installed())); + expect(await probe.check()).toBe(true); const original = probe.binding(); + expect(await probe.check()).toBe(true); expect(probe.binding()).toEqual(original); + await probe.close(); +}); + +test("a successful query completed after close cannot restore private proof", async () => { + let release!: (value: DesktopAppInstall) => void; + const probe = createInstalledBuildProbe(() => new Promise(resolve => { release = resolve; })); + const checking = probe.check(); await Promise.resolve(); + const closing = probe.close(); const closedBinding = probe.binding(); + release(installed()); expect(await checking).toBe(false); await closing; + expect(probe.binding()).toEqual(closedBinding); expect(probe.lastResult()).toBeNull(); }); test("matching version text cannot qualify a foreign package family, publisher or app entry", async () => { diff --git a/tests/clients/desktop-compatibility-runtime.test.ts b/tests/clients/desktop-compatibility-runtime.test.ts index cab0a74d7e8..09180163551 100644 --- a/tests/clients/desktop-compatibility-runtime.test.ts +++ b/tests/clients/desktop-compatibility-runtime.test.ts @@ -4,8 +4,9 @@ import { afterEach, describe, expect, test } from "bun:test"; import { createCertificateAuthority } from "../../src/claude/intercept/local-ca"; import { X509Certificate } from "node:crypto"; import { createDesktopCompatibilityRuntime, type DesktopRuntimeIo } from "../../src/codex/desktop-compatibility/runtime"; -import { createInstalledBuildProbe, DESKTOP_COMPATIBILITY_ASSESSED_VERSION, DESKTOP_COMPATIBILITY_ASSESSED_FAMILY } from "../../src/codex/desktop-compatibility/installed-build"; -import type { DesktopAppInstall } from "../../src/codex/desktop-app/types"; +import { createInstalledBuildProbe, DESKTOP_COMPATIBILITY_ASSESSED_VERSION } from "../../src/codex/desktop-compatibility/installed-build"; +import type { DesktopPackageObservation as DesktopAppInstall } from "../../src/codex/desktop-compatibility/installed-build"; +import { desktopPackageObservation } from "../helpers/desktop-package-observation"; import { UsageRelayController, type UsageIdentity } from "../../src/codex/desktop-compatibility/usage-controller"; import { desktopCompatibilityRuntimeActive, readDesktopCompatibilityLaunch } from "../../src/codex/desktop-compatibility/runtime-ownership"; import { captureWindowsCompatibilityContext, assertWindowsCompatibilityContext } from "../../src/codex/desktop-compatibility/windows-package-command"; @@ -206,9 +207,85 @@ function fixture(trusted = true, connectionStore?: DesktopConnectionStore, build } describe("optional native compatibility runtime", () => { - const assessedInstall = (): DesktopAppInstall => ({ id: DESKTOP_COMPATIBILITY_ASSESSED_FAMILY, - root: `C:\\Program Files\\WindowsApps\\OpenAI.Codex_${DESKTOP_COMPATIBILITY_ASSESSED_VERSION}_x64__2p2nqsd0c76g0`, - relaunch: `${DESKTOP_COMPATIBILITY_ASSESSED_FAMILY}!App` }); + const assessedInstall = desktopPackageObservation; + + for (const completion of ["success", "rejection"] as const) { + test(`late ${completion} from a replaced runtime cannot invalidate its successor`, async () => { + let hold = false, entered!: () => void, resolveOld!: (value: boolean) => void, rejectOld!: (error: Error) => void; + const enteredPromise = new Promise(resolve => { entered = resolve; }); + // The override isolates the runtime epoch fence: production probe.close() separately drains its child. + const io = fixture(true, undefined, () => { + if (!hold) return true; + entered(); return new Promise((resolve, reject) => { resolveOld = resolve; rejectOld = reject; }); + }); + const read = async () => { + const pac = await fetch(io.runtime.getPacUrl()!).then(response => response.text()); + const port = /PROXY 127\.0\.0\.1:(\d+)/.exec(pac)![1]; + return fetch("https://chatgpt.com/backend-api/wham/usage", { + proxy: `http://127.0.0.1:${port}`, tls: { ca: io.authority.certPem }, + }).then(response => response.json()); + }; + await io.runtime.start(); await read(); hold = true; + const oldApply = io.runtime.apply(true).catch(error => error); + await enteredPromise; + try { + hold = false; await io.runtime.stop(); await io.runtime.start(); + expect((await io.runtime.apply(true)).accepted).toBe(false); + expect(await read()).toEqual(usage); + expect((await io.runtime.apply(true)).accepted).toBe(true); + const generation = io.runtime.status().usage!.generation; + if (completion === "success") resolveOld(true); else rejectOld(new Error("old fixture proof rejected")); + expect(await oldApply).toBeInstanceOf(Error); + expect(io.runtime.status().contextFailure).toBeNull(); + expect(io.runtime.status().usage).toMatchObject({ mode: "apply", generation }); + expect((await read()).rate_limit.allowed).toBe(true); + } finally { + resolveOld(true); await oldApply; await io.runtime.stop(); + } + }, 15_000); + } + + for (const failure of ["same-version-replacement", "unverifiable", "ABA"] as const) { + test(`${failure} revokes package-bound trial consent before another corrected response`, async () => { + let current: DesktopAppInstall | null = assessedInstall(); + let probe!: ReturnType; + const io = fixture(true, undefined, undefined, () => (probe = createInstalledBuildProbe(async () => current))); + await io.runtime.start(); + const pac = await fetch(io.runtime.getPacUrl()!).then(res => res.text()); + const port = /PROXY 127\.0\.0\.1:(\d+)/.exec(pac)![1]; + const read = () => fetch("https://chatgpt.com/backend-api/wham/usage", { + proxy: `http://127.0.0.1:${port}`, tls: { ca: io.authority.certPem }, + }).then(res => res.json()); + await read(); expect((await io.runtime.apply(true)).accepted).toBe(true); + expect((await read()).rate_limit.allowed).toBe(true); + const generation = io.runtime.status().usage!.generation; + current = failure === "unverifiable" ? null + : { ...assessedInstall(), proof: { ...assessedInstall().proof!, executableIdentity: "1024:100:201" } }; + if (failure === "ABA") { + expect(await probe.check()).toBe(true); + current = assessedInstall(); expect(await probe.check()).toBe(true); + } + expect(await read()).toEqual(usage); + expect(io.runtime.status().usage).toMatchObject({ mode: "observe", outputs: 0 }); + expect(io.runtime.status().usage!.generation).toBeGreaterThan(generation); + if (failure === "unverifiable") { + current = assessedInstall(); + // Recovery is another identity transition, not permission to revive consent. + await expect(io.runtime.apply(true)).rejects.toThrow("build_unverified"); + } + expect((await io.runtime.apply(true)).accepted).toBe(false); + expect(await read()).toEqual(usage); + expect((await io.runtime.apply(false)).accepted).toBe(false); + expect((await io.runtime.apply(true)).accepted).toBe(true); + expect((await read()).rate_limit.allowed).toBe(true); + expect(JSON.stringify(io.runtime.status())).not.toContain("ChatGPT.exe"); + expect(JSON.stringify(io.runtime.status())).not.toContain("manifestDigest"); + await io.runtime.stop(); await io.runtime.start(); + expect(io.runtime.status().usage?.mode).toBe("observe"); + expect((await io.runtime.apply(true)).accepted).toBe(false); + await io.runtime.stop(); + }, 15_000); + } test("stop/start retains a completed assessment through routing failure and replacement probe pending", async () => { let created = 0, queries = 0, hold = false, entered!: () => void, release!: (install: DesktopAppInstall | null) => void; diff --git a/tests/helpers/desktop-package-observation.ts b/tests/helpers/desktop-package-observation.ts new file mode 100644 index 00000000000..89af754625c --- /dev/null +++ b/tests/helpers/desktop-package-observation.ts @@ -0,0 +1,14 @@ +import { win32 } from "node:path"; +import { DESKTOP_COMPATIBILITY_ASSESSED_FAMILY, DESKTOP_COMPATIBILITY_ASSESSED_VERSION, + type DesktopPackageObservation } from "../../src/codex/desktop-compatibility/installed-build"; + +/** Synthetic registered manifest/file facts; never performs a host package query. */ +export function desktopPackageObservation(version = DESKTOP_COMPATIBILITY_ASSESSED_VERSION): DesktopPackageObservation { + const packageFullName = `OpenAI.Codex_${version}_x64__2p2nqsd0c76g0`; + const root = win32.join("C:\\Program Files\\WindowsApps", packageFullName); + return { id: DESKTOP_COMPATIBILITY_ASSESSED_FAMILY, root, relaunch: `${DESKTOP_COMPATIBILITY_ASSESSED_FAMILY}!App`, + proof: { packageFullName, publisherId: "2p2nqsd0c76g0", publisher: "CN=Fixture Registered Publisher", + manifestName: "OpenAI.Codex", manifestPublisher: "CN=Fixture Registered Publisher", manifestVersion: version, + appId: "App", executable: "app\\ChatGPT.exe", canonicalExecutable: win32.join(root, "app", "ChatGPT.exe"), + manifestDigest: "a".repeat(64), executableIdentity: "1024:100:200", ancestryIdentity: "c".repeat(64) } }; +} From 1db7b61a9fdaad0b1864ec55e4e942a5b7ea8e40 Mon Sep 17 00:00:00 2001 From: ingwannu Date: Mon, 26 Oct 2026 19:14:57 +0900 Subject: [PATCH 076/101] fix(chat): fail unmet required client tool completions (#7463) A translated Chat completion that ends cleanly without the required client tool call now fails with non-retryable required_tool_not_called, preserving usage and delivered output; combo stream preflight treats that verdict as final so no other target is sent the turn. Refs #7456. --- .../content/docs/reference/proxy-formats.md | 12 ++ scripts/test-layout/layout-chat.json | 4 +- src/adapters/openai-chat.ts | 6 +- .../openai-chat/required-tool-result.ts | 64 ++++++++ .../responses/combo-stream-preflight.ts | 9 +- structure/INDEX.md | 2 + .../ADR-7456-mandatory-chat-tool-results.md | 9 ++ structure/manifest.json | 7 + .../providers/chat-required-tool-results.md | 5 + .../openai/openai-chat-required-tool.test.ts | 46 ++++++ tests/fixtures/test-layout-expected.json | 2 + .../responses-required-chat-tool.test.ts | 153 ++++++++++++++++++ 12 files changed, 314 insertions(+), 5 deletions(-) create mode 100644 src/adapters/openai-chat/required-tool-result.ts create mode 100644 structure/decisions/ADR-7456-mandatory-chat-tool-results.md create mode 100644 structure/providers/chat-required-tool-results.md create mode 100644 tests/adapters/openai/openai-chat-required-tool.test.ts create mode 100644 tests/responses/responses-required-chat-tool.test.ts diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index fe6d7330b18..bf4d17fe15b 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -20,6 +20,18 @@ response safety still happen at the proxy boundary. Configure the listener and a [Configuration](/reference/configuration/); use [Combos](/guides/combos/) when one public model id should select among several targets. +## Required tools on translated Chat routes + +A translated OpenAI Chat response must contain a complete selected client tool call when +`tool_choice` is required, names a tool, or uses a required allowed-tool selection. If the upstream +finishes normally without one, the turn fails with `required_tool_not_called` and preserves usage. +Already streamed text stays visible; the proxy does not replay the request or switch targets after +that output. A streamed Responses reply ends with `response.failed`; an admitted buffered Responses +envelope has `status: "failed"` even when its HTTP status is 200. Check the envelope status. +Auto/none choices and truncated outcomes keep their existing behavior. This guard covers translated +Chat client-tool catalogs; catalogs containing proxy-executed sidecars, native passthrough and other +adapters retain their existing completion rules. + ## MiMo bare tool-call recovery On translated OpenAI Chat routes, `mimo-v2` and dotted `mimo-v2.*` model IDs can recover a diff --git a/scripts/test-layout/layout-chat.json b/scripts/test-layout/layout-chat.json index 02276512f61..5085190f082 100644 --- a/scripts/test-layout/layout-chat.json +++ b/scripts/test-layout/layout-chat.json @@ -1,3 +1,5 @@ { - "chat-incomplete-length-reason.test.ts": "responses" + "chat-incomplete-length-reason.test.ts": "responses", + "openai-chat-required-tool.test.ts": "adapters/openai", + "responses-required-chat-tool.test.ts": "responses" } diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 99663eee60e..bd77e101a3b 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -1,3 +1,4 @@ +import { withRequiredChatToolResult } from "./openai-chat/required-tool-result"; import { hasShrinkableOpenAIChatImages, normalizeOpenAIChatImages } from "./openai-chat-images"; import { protectGlmSummaryBudget, resolveMaxTokens } from "./openai-chat/summary-budget"; import { chatParallelToolCallsWireValue } from "./openai-chat/parallel-tool-calls"; @@ -49,7 +50,6 @@ import { openAIChatTransport, stripBracketedModelSuffix } from "./openai-chat/wi import { toolChoiceToChatFormat, toolsToChatFormatForProvider } from "./openai-chat/tool-schema"; import { freeformToolsByWireName, type FreeformToolIdentity, reconcileStructuredToolCall, reconcileStructuredToolCalls } from "./openai-chat/serialized-tool-call-content"; import { StandaloneBareToolCallBuffer, reconcileStandaloneBareToolCallEvents } from "./openai-chat/standalone-bare-tool-calls"; - export { stripBracketedModelSuffix } from "./openai-chat/wire"; export { buildOpenAIChatPassthroughRequest } from "./openai-chat/passthrough"; export { formatOpenAIChatErrorBody } from "./openai-chat/errors"; @@ -91,7 +91,7 @@ function canSerializeOpenAIChatServiceTier( export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAdapter { let lastRequestedModelId: string | undefined; let freeformTools = new Map(); - return withOpenAIChatToolNames(toolNames => ({ + return withRequiredChatToolResult(withOpenAIChatToolNames(toolNames => ({ name: "openai-chat", formatErrorBody: formatOpenAIChatErrorBody, @@ -818,5 +818,5 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd budget.releaseRetained(responseBytes, { kind: "retained_collectors" }); } }, - })); + }))); } diff --git a/src/adapters/openai-chat/required-tool-result.ts b/src/adapters/openai-chat/required-tool-result.ts new file mode 100644 index 00000000000..180af96e7c4 --- /dev/null +++ b/src/adapters/openai-chat/required-tool-result.ts @@ -0,0 +1,64 @@ +import type { ProviderAdapter } from "../base"; +import { requiresToolCall, type AdapterEvent, type OcxParsedRequest } from "../../types"; +import { releaseTranslatedEvent, retainTranslatedEvent, type TranslatorBudget } from "../../lib/translator-budget"; +import type { AdapterTierMetadata } from "../../providers/fastwire"; +import { isTruncatedStopReason } from "../../responses/truncated-stop-reason"; + +function requiresClientCall(parsed: OcxParsedRequest): boolean { + if (!requiresToolCall(parsed.options.toolChoice)) return false; + // Sidecars own their own tool lifecycle; this boundary validates client-executed calls only. + return !(parsed.context.tools ?? []).some(t => t.webSearch || t.imageGeneration || t.videoGeneration || t.advisor); +} + +function terminalGuard(required: boolean): (event: AdapterEvent) => AdapterEvent { + // The shared response bridge owns tool-choice authorization and alias restoration. + // Count a completed call here without introducing a second, narrower name resolver. + let pending = false; + let fulfilled = false; + let failed = false; + return event => { + if (event.type === "tool_call_start") pending = true; + if (event.type === "tool_call_end") { fulfilled ||= pending; pending = false; } + if (event.type === "error" || event.type === "incomplete") failed = true; + if (event.type !== "done" || !required || fulfilled || failed || isTruncatedStopReason(event.stopReason)) return event; + // Preserve delivered output and consumption. A mandatory call is a contract, not a hint + // that permits replaying an already-visible turn or silently reporting text as success. + return { + type: "error", status: 502, errorType: "upstream_error", code: "required_tool_not_called", + message: "The upstream response completed without the required client tool call.", + retryable: false, usage: event.usage, + }; + }; +} + +/** Keep the same per-request lifetime as the Chat adapter's tool-name registry. */ +export function withRequiredChatToolResult(adapter: ProviderAdapter): ProviderAdapter { + let required = false; + return { + ...adapter, + buildRequest(parsed, incoming) { + required = requiresClientCall(parsed); + return adapter.buildRequest(parsed, incoming); + }, + async *parseStream(response, budget, tierMetadata) { + const guard = terminalGuard(required); + for await (const event of adapter.parseStream(response, budget, tierMetadata)) yield guard(event); + }, + ...(adapter.parseResponse ? { + async parseResponse(response: Response, budget: TranslatorBudget, tierMetadata?: AdapterTierMetadata) { + const events = await adapter.parseResponse!(response, budget, tierMetadata); + const guard = terminalGuard(required); + for (let i = 0; i < events.length; i++) { + const original = events[i]!; + const guarded = guard(original); + if (guarded !== original) { + releaseTranslatedEvent(original, budget); + retainTranslatedEvent(guarded, budget); + events[i] = guarded; + } + } + return events; + }, + } : {}), + }; +} diff --git a/src/server/responses/combo-stream-preflight.ts b/src/server/responses/combo-stream-preflight.ts index 1a7b64bb26d..50781c7fc55 100644 --- a/src/server/responses/combo-stream-preflight.ts +++ b/src/server/responses/combo-stream-preflight.ts @@ -29,6 +29,10 @@ const TERMINAL_EVENTS = new Set([ "response.incomplete", ]); +// A failed terminal carrying one of these codes is a contract verdict, not a transient +// upstream failure: another target would spend a request the adapter already refused. +const NON_REPLAYABLE_FAILED_CODES = new Set(["required_tool_not_called"]); + const RETRYABLE_ZERO_OUTPUT_INCOMPLETE_REASONS = new Set([ "adapter_eof", "missing_terminal_event", @@ -118,7 +122,10 @@ function retryableZeroOutputTerminal(payload: unknown): boolean { response?: { incomplete_details?: { reason?: unknown }; output?: unknown }; }; if (bareErrorIsRetryable(event)) return true; - if (event.type === "response.failed") return true; + if (event.type === "response.failed") { + const code = (event.response as { error?: { code?: unknown } } | undefined)?.error?.code; + return !(typeof code === "string" && NON_REPLAYABLE_FAILED_CODES.has(code)); + } if (event.type !== "response.incomplete") return false; const reason = event.response?.incomplete_details?.reason; // A terminal-only upstream can carry output without preceding delta/item events. diff --git a/structure/INDEX.md b/structure/INDEX.md index 78f111d9ca4..99683ebea5d 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -100,6 +100,7 @@ Per-vendor contracts and the adapter authority that constructs them. | [`providers/xai-grok.md`](providers/xai-grok.md) | Grok Build contract parity and hardening. | | [`providers/chat-compat.md`](providers/chat-compat.md) | Cross-vendor Chat Completions behavior: reasoning, tool results, structured output, parallel tools. | | [`providers/mimo-bare-calls.md`](providers/mimo-bare-calls.md) | Standalone MiMo bare-block recovery into declared freeform calls, separate from identified-wrapper reconciliation. | +| [`providers/chat-required-tool-results.md`](providers/chat-required-tool-results.md) | Translated Chat terminal guard that fails a clean completion missing a mandatory client tool call. | | [`adapters/registry.md`](adapters/registry.md) | The single adapter construction authority and contract inheritance. | | [`adapters/compatibility-contracts.md`](adapters/compatibility-contracts.md) | Versioned provider compatibility claims and fixture-evidence boundaries. | | [`adapters/compatibility-lab.md`](adapters/compatibility-lab.md) | Optional Lab evidence, automation, and its core-runtime isolation boundary. | @@ -180,6 +181,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/adapters/dsh-account.ts` | [`providers/dsh-account.md`](providers/dsh-account.md) | | `src/adapters/openai-chat.ts` | [`providers/mimo-bare-calls.md`](providers/mimo-bare-calls.md) | | `src/adapters/openai-chat/` | [`providers/mimo-bare-calls.md`](providers/mimo-bare-calls.md) | +| `src/adapters/openai-chat/required-tool-result.ts` | [`providers/chat-required-tool-results.md`](providers/chat-required-tool-results.md) | | `src/adapters/openai-responses/opengateway-input.ts` | [`providers/opengateway.md`](providers/opengateway.md) | | `src/adapters/openai-responses/passthrough.ts` | [`transports/responses-citations.md`](transports/responses-citations.md)
[`providers/opengateway.md`](providers/opengateway.md) | | `src/adapters/openai-responses/request-strips.ts` | [`transports/responses-citations.md`](transports/responses-citations.md) | diff --git a/structure/decisions/ADR-7456-mandatory-chat-tool-results.md b/structure/decisions/ADR-7456-mandatory-chat-tool-results.md new file mode 100644 index 00000000000..0670bd62d84 --- /dev/null +++ b/structure/decisions/ADR-7456-mandatory-chat-tool-results.md @@ -0,0 +1,9 @@ +# ADR-7456 — Mandatory translated Chat tool results + +- Contract owner: [providers/chat-required-tool-results.md](../providers/chat-required-tool-results.md) +- Intent: Stop reporting a successful turn when an upstream Chat model ignores the caller's mandatory client-tool selection (#7456). +- Prior constraints: Forwarding `tool_choice` does not establish that a call occurred. A streamed answer may already be committed when the clean terminal arrives. Existing adapter parsers restore tool identities and own argument validation and byte accounting. +- Alternatives: Model/provider allowlists, replaying the same turn with a prompt nudge, rerouting a committed stream, or buffering every response until completion. These require separate capability evidence or violate the existing output/commit and memory boundaries. +- Choice: Reuse the shared mandatory-choice predicate in a small adapter decorator. Observe complete calls without retaining output. Leave selected-name authorization and namespace/code-mode alias restoration with the existing response bridge; an independent adapter name matcher would incorrectly reject legitimate echoes. Replace only a clean unmet terminal with a typed non-retryable error, retaining usage. Transfer the buffered terminal's existing retention lease to its replacement. +- Why: The actual result establishes the contract without inventing vendor capability or asking the upstream to execute twice. Direct and combo routes receive the same adapter verdict; their existing response owner keeps transport and commit decisions. +- Consequences: Already delivered text remains visible in a failed response. Admitted buffered Responses can retain HTTP 200 while their envelope has `status: failed`; streaming ends with `response.failed`. This patch does not preflight or reroute runTurn adapters, validate native passthrough, promise automatic recovery, or define a global capability registry. Sidecar-bearing catalogs remain with their existing execution owner, while client function, custom and tool-search calls use the restored adapter identity. diff --git a/structure/manifest.json b/structure/manifest.json index 273909d8ebf..bee1f5904f1 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -679,6 +679,13 @@ "scope": "Standalone MiMo bare-block recovery into declared freeform calls, separate from identified-wrapper reconciliation.", "documents": ["src/adapters/openai-chat/", "src/adapters/openai-chat.ts"] }, + { + "path": "providers/chat-required-tool-results.md", + "tier": 4, + "title": "Mandatory Translated Chat Tool Results", + "scope": "Translated Chat terminal guard that fails a clean completion missing a mandatory client tool call.", + "documents": ["src/adapters/openai-chat/required-tool-result.ts"] + }, { "path": "adapters/registry.md", "tier": 4, diff --git a/structure/providers/chat-required-tool-results.md b/structure/providers/chat-required-tool-results.md new file mode 100644 index 00000000000..03ea9b10f3c --- /dev/null +++ b/structure/providers/chat-required-tool-results.md @@ -0,0 +1,5 @@ +# Mandatory Translated Chat Tool Results + +`src/adapters/openai-chat/required-tool-result.ts` requires a completed client tool call before a clean terminal for required, named or required allowed-tool choices. Text and reasoning do not satisfy it. The existing response bridge owns selected-name authorization and alias restoration, so valid namespace/code-mode echoes keep their existing interpretation. Missing required calls become a non-retryable `required_tool_not_called` error with upstream usage preserved. Earlier output stays delivered and the guard neither buffers a stream nor sends another request. Existing errors and truncated outcomes retain their classification; auto/none remain unchanged. Sidecar-bearing catalogs bypass this client-call guard because their execution owner governs completion. Native passthrough and other adapters retain their existing contracts. When the failure ends a stream that produced no output, combo stream preflight (`src/server/responses/combo-stream-preflight.ts`) treats a `response.failed` carrying `required_tool_not_called` as final, so the combo does not send the same turn to another target. + +> Decision record: [ADR-7456](../decisions/ADR-7456-mandatory-chat-tool-results.md) diff --git a/tests/adapters/openai/openai-chat-required-tool.test.ts b/tests/adapters/openai/openai-chat-required-tool.test.ts new file mode 100644 index 00000000000..4d8e0359e5f --- /dev/null +++ b/tests/adapters/openai/openai-chat-required-tool.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from "bun:test"; +import { createOpenAIChatAdapter } from "../../../src/adapters/openai-chat"; +import { createTestTranslatorBudget } from "../../helpers/translator-budget"; +import type { AdapterEvent, OcxParsedRequest, OcxToolChoice } from "../../../src/types"; + +const usage = { prompt_tokens: 7, completion_tokens: 2, total_tokens: 9 }; +const tools = Array.from({ length: 151 }, (_, i) => ({ name: `marker_${i}`, description: "Synthetic marker", parameters: { type: "object", properties: {} } })); +const request = (choice: OcxToolChoice | undefined): OcxParsedRequest => ({ modelId: "fixture", stream: false, + context: { messages: [{ role: "user", content: "Use a marker", timestamp: 0 }], tools }, options: { toolChoice: choice } }); +async function parse(choice: OcxToolChoice | undefined, stream: boolean, call?: string, finish = "stop") { + const adapter = createOpenAIChatAdapter({ adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1", apiKey: "fixture-key" }); + await adapter.buildRequest(request(choice), { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }); + const fn = { id: "call_fixture", type: "function", function: { name: call, arguments: "{}" } }; + if (!stream) return adapter.parseResponse!(Response.json({ choices: [{ message: { content: "visible text", ...(call ? { tool_calls: [fn] } : {}) }, finish_reason: finish }], usage }), createTestTranslatorBudget()); + const chunks = [ + { choices: [{ delta: { content: "visible text" }, finish_reason: null }] }, + ...(call ? [{ choices: [{ delta: { tool_calls: [{ ...fn, index: 0 }] }, finish_reason: null }] }] : []), + { choices: [{ delta: {}, finish_reason: finish }], usage }, + ]; + const response = new Response(chunks.map(x => `data: ${JSON.stringify(x)}\n\n`).join("") + "data: [DONE]\n\n", { headers: { "content-type": "text/event-stream" } }); + const events: AdapterEvent[] = []; + for await (const e of adapter.parseStream(response, createTestTranslatorBudget())) events.push(e); + return events; +} +for (const stream of [false, true]) { + test(`required tool missing is a typed terminal failure with usage (stream=${stream})`, async () => { + const events = await parse("required", stream); + expect(events.at(-1)).toMatchObject({ type: "error", status: 502, code: "required_tool_not_called", usage: { inputTokens: 7, outputTokens: 2 } }); + expect(events.some(e => e.type === "done")).toBe(false); + expect(events.some(e => e.type === "text_delta" && e.text === "visible text")).toBe(true); + }); + test(`a named mandatory request without a call fails (stream=${stream})`, async () => { + expect((await parse({ name: "marker_0" }, stream)).at(-1)).toMatchObject({ type: "error", code: "required_tool_not_called" }); + }); + for (const choice of ["auto", "none", undefined] as const) test(`ordinary text succeeds for ${choice} (stream=${stream})`, async () => { + expect((await parse(choice, stream)).at(-1)?.type).toBe("done"); + }); + for (const choice of ["required", { name: "marker_150" }, { allowedTools: ["marker_150"], mode: "required" }] as const) test(`a declared required tool survives the full 151-tool catalog (stream=${stream}, choice=${JSON.stringify(choice)})`, async () => { + const events = await parse(choice, stream, "marker_150", "tool_calls"); + expect(events.at(-1)?.type).toBe("done"); + expect(events.filter(e => e.type === "tool_call_start")).toHaveLength(1); + }); + for (const finish of ["length", "content_filter"]) test(`non-success terminal ${finish} retains its existing outcome (stream=${stream})`, async () => { + expect((await parse("required", stream, undefined, finish)).at(-1)).toMatchObject({ type: "done", stopReason: finish === "length" ? "max_tokens" : "content_filter" }); + }); +} diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index ffd401ece74..785c3f4659a 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -344,6 +344,8 @@ "chat-anthropic-reasoning-details.test.ts": "responses", "chat-inline-document-bytes.test.ts": "responses", "chat-json-sse-fallback.test.ts": "responses", "chat-incomplete-length-reason.test.ts": "responses", + "openai-chat-required-tool.test.ts": "adapters/openai", + "responses-required-chat-tool.test.ts": "responses", "chat-media-translation.test.ts": "responses", "chat-native-developer-role.test.ts": "responses", "chat-native-reasoning-replay.test.ts": "responses", "chat-native-reasoning-scope.test.ts": "responses", diff --git a/tests/responses/responses-required-chat-tool.test.ts b/tests/responses/responses-required-chat-tool.test.ts new file mode 100644 index 00000000000..337742c3269 --- /dev/null +++ b/tests/responses/responses-required-chat-tool.test.ts @@ -0,0 +1,153 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { handleResponses } from "../../src/server/responses/core"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; + +let home: TempHome; +let release: () => void; +beforeEach(() => { home = createTempHome(); release = acquireOwnedSpendHome(); }); +afterEach(() => { release(); home.remove(); }); + +const tools = Array.from({ length: 151 }, (_, i) => ({ type: "function", name: `marker_${i}`, description: "Synthetic marker", parameters: { type: "object", properties: {} } })); +for (const stream of [false, true]) for (const combo of [false, true]) for (const tool of [false, true]) { + test(`mandatory Chat result through Responses (stream=${stream}, combo=${combo}, tool=${tool})`, async () => { + const sent: { tool_choice: unknown; tools: unknown[]; stream?: boolean }[] = []; + let fallbackSends = 0; + const provider = (name: string): OcxProviderConfig => ({ + adapter: "openai-chat", baseUrl: `https://${name}.invalid/v1`, apiKey: "fixture-key", authMode: "key", models: ["fixture"], + fetch: (async (_input: RequestInfo | URL, init?: RequestInit) => { + if (name === "fallback") { fallbackSends++; throw new Error("committed output must not be replayed"); } + const body = JSON.parse(String(init?.body)); sent.push(body); + const call = { id: "call_fixture", type: "function", function: { name: "marker_150", arguments: "{}" } }; + const usage = { prompt_tokens: 7, completion_tokens: 2, total_tokens: 9 }; + if (!body.stream) return Response.json({ choices: [{ message: { role: "assistant", content: "visible text", ...(tool ? { tool_calls: [call] } : {}) }, finish_reason: tool ? "tool_calls" : "stop" }], usage }); + const chunks = [ + { choices: [{ delta: { content: "visible text" }, finish_reason: null }] }, + ...(tool ? [{ choices: [{ delta: { tool_calls: [{ ...call, index: 0 }] }, finish_reason: null }] }] : []), + { choices: [{ delta: {}, finish_reason: tool ? "tool_calls" : "stop" }], usage }, + ]; + return new Response(chunks.map(x => `data: ${JSON.stringify(x)}\n\n`).join("") + "data: [DONE]\n\n", { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch, + }); + const cfg = { defaultProvider: "primary", providers: { primary: provider("primary"), fallback: provider("fallback") }, + combos: { required: { alias: "chain/required", strategy: "failover", targets: [{ provider: "primary", model: "fixture" }, { provider: "fallback", model: "fixture" }] } }, + } as OcxConfig; + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: combo ? "chain/required" : "primary/fixture", input: "Use a marker", store: false, stream, tools, tool_choice: "required" }), + }), cfg, { model: "", provider: "" }); + const text = await response.text(); + expect(sent).toHaveLength(1); + expect(fallbackSends).toBe(0); + expect(sent[0]!.tool_choice).toBe("required"); + expect(sent[0]!.tools).toHaveLength(151); + if (tool) { + expect(response.status).toBe(200); + expect(text).toContain("marker_150"); + expect(text).toContain(stream ? "response.completed" : '"status":"completed"'); + } else { + expect(text).toContain("required_tool_not_called"); + expect(text).not.toContain("response.completed"); + const terminal = stream + ? JSON.parse(text.split("\n").filter(line => line.startsWith("data: {")).map(line => line.slice(6)).find(line => JSON.parse(line).type === "response.failed")!).response + : JSON.parse(text); + expect(terminal.status).toBe("failed"); + expect(terminal.usage).toMatchObject({ input_tokens: 7, output_tokens: 2 }); + expect(terminal.retryable).toBe(false); + if (stream) expect(text).toContain("visible text"); + else expect(response.status).toBe(200); // Admitted Responses envelopes carry terminal failure in their status. + } + }); +} + +test("a 151-tool call/result continuation retains the catalog and result", async () => { + const bodies: { messages: { role: string; content: unknown; tool_call_id?: string }[]; tools: unknown[]; tool_choice?: unknown }[] = []; + const cfg = { defaultProvider: "primary", providers: { primary: { + adapter: "openai-chat", baseUrl: "https://primary.invalid/v1", authMode: "key", apiKey: "fixture-key", + fetch: (async (_input: RequestInfo | URL, init?: RequestInit) => { + bodies.push(JSON.parse(String(init?.body))); + return Response.json({ choices: [{ message: bodies.length === 1 + ? { role: "assistant", tool_calls: [{ id: "call_cycle", type: "function", function: { name: "marker_150", arguments: "{}" } }] } + : { role: "assistant", content: "result received" }, finish_reason: bodies.length === 1 ? "tool_calls" : "stop" }] }); + }) as typeof fetch, + } } } as OcxConfig; + const send = (input: unknown, choice: string) => handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "primary/fixture", stream: false, store: false, input, tools, tool_choice: choice }), + }), cfg, { model: "", provider: "" }); + const first = await (await send("Use a marker", "required")).json(); + expect(first.status).toBe("completed"); + const call = first.output.find((item: { type: string }) => item.type === "function_call"); + expect(call.name).toBe("marker_150"); + const second = await (await send([{ role: "user", content: "Use a marker" }, call, { type: "function_call_output", call_id: call.call_id, output: "synthetic marker result" }], "auto")).json(); + expect(second.status).toBe("completed"); + expect(JSON.stringify(second.output)).toContain("result received"); + expect(bodies).toHaveLength(2); + expect(bodies[1]!.tools).toHaveLength(151); + expect(bodies[1]!.messages).toContainEqual({ role: "tool", tool_call_id: call.call_id, content: "synthetic marker result" }); +}); + +for (const call of ["remote.lookup", "lookup"]) test(`existing namespace echo restoration still satisfies required: ${call}`, async () => { + const cfg = { defaultProvider: "primary", providers: { primary: { + adapter: "openai-chat", baseUrl: "https://primary.invalid/v1", authMode: "key", apiKey: "fixture-key", + fetch: (async () => Response.json({ choices: [{ message: { role: "assistant", tool_calls: [{ id: "call_alias", type: "function", function: { name: call, arguments: "{}" } }] }, finish_reason: "tool_calls" }] })) as typeof fetch, + } } } as OcxConfig; + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "primary/fixture", stream: false, store: false, input: "Use lookup", tool_choice: "required", + tools: [{ type: "namespace", name: "remote", tools: [{ type: "function", name: "lookup", parameters: { type: "object", properties: {} } }] }] }), + }), cfg, { model: "", provider: "" }); + const body = await response.json(); + expect(body.status).toBe("completed"); + expect(body.output.find((item: { type: string }) => item.type === "function_call")).toMatchObject({ name: "lookup", namespace: "remote" }); +}); + +for (const stream of [false, true]) for (const combo of [false, true]) test(`existing bridge rejects a wrong named call (stream=${stream}, combo=${combo})`, async () => { + let sends = 0; + const primary: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://primary.invalid/v1", authMode: "key", apiKey: "fixture-key", + fetch: (async (_input: RequestInfo | URL, init?: RequestInit) => { + sends++; + const call = { id: "call_wrong", type: "function", function: { name: "marker_1", arguments: "{}" } }; + if (JSON.parse(String(init?.body)).stream) return new Response([ + { choices: [{ delta: { tool_calls: [{ ...call, index: 0 }] }, finish_reason: null }] }, + { choices: [{ delta: {}, finish_reason: "tool_calls" }] }, + ].map(chunk => `data: ${JSON.stringify(chunk)}\n\n`).join("") + "data: [DONE]\n\n", { headers: { "content-type": "text/event-stream" } }); + return Response.json({ choices: [{ message: { role: "assistant", tool_calls: [call] }, finish_reason: "tool_calls" }] }); + }) as typeof fetch }; + const cfg = { defaultProvider: "primary", providers: { primary }, combos: { required: { alias: "chain/required", strategy: "failover", targets: [{ provider: "primary", model: "fixture" }] } } } as OcxConfig; + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: combo ? "chain/required" : "primary/fixture", stream, store: false, input: "Use marker_0", tools, tool_choice: { type: "function", name: "marker_0" } }), + }), cfg, { model: "", provider: "" }); + const body = await response.text(); + expect(body).toContain("upstream_server_error"); + expect(body).not.toContain("response.output_item.done"); + expect(body).not.toContain("response.completed"); + expect(sends).toBe(1); +}); + +test("a zero-output streamed combo child that skips a required call stops the combo", async () => { + let fallbackSends = 0; + const provider = (name: string): OcxProviderConfig => ({ + adapter: "openai-chat", baseUrl: `https://${name}.invalid/v1`, apiKey: "fixture-key", authMode: "key", models: ["fixture"], + fetch: (async () => { + if (name === "silent-fallback") { fallbackSends++; return Response.json({ choices: [{ message: { role: "assistant", content: "replayed" }, finish_reason: "stop" }] }); } + const chunks = [{ choices: [{ delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 7, completion_tokens: 0, total_tokens: 7 } }]; + return new Response(chunks.map(x => `data: ${JSON.stringify(x)}\n\n`).join("") + "data: [DONE]\n\n", { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch, + }); + // Distinct provider names keep earlier combo failures in this file from cooling down the primary. + const cfg = { defaultProvider: "silent-primary", providers: { "silent-primary": provider("silent-primary"), "silent-fallback": provider("silent-fallback") }, + combos: { silent: { alias: "chain/silent-required", strategy: "failover", targets: [{ provider: "silent-primary", model: "fixture" }, { provider: "silent-fallback", model: "fixture" }] } }, + } as OcxConfig; + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "chain/silent-required", input: "Use a marker", store: false, stream: true, tools, tool_choice: "required" }), + }), cfg, { model: "", provider: "" }); + const text = await response.text(); + expect(fallbackSends).toBe(0); + expect(text).toContain("required_tool_not_called"); + expect(text).not.toContain("replayed"); + expect(text).not.toContain("response.completed"); +}); From 66e089242f2a54f51b7d60a060b4ea390cae9374 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Mon, 26 Oct 2026 10:19:14 +0000 Subject: [PATCH 077/101] fix(ci): verify installer signature before administrative extraction (#7421) * fix(ci): verify installer signature before administrative extraction * test(ci): assert rejected installer paths never inspect inner signatures * test: cover rejected MSI and inner release signatures * fix(test): promote Bun 1.4.4 with independent runner role verification * docs: align Docker Bun comments with unified role defaults * test: use a draining TLS origin for early upload replies * test: confine draining origin to buffered HTTP/1 upload --------- Co-authored-by: Epinephrine <27862058+luvs01@users.noreply.github.com> Co-authored-by: JUN Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> --- scripts/ci/verify-authenticode.ps1 | 112 +++--- .../release-windows-signing.test.ts | 326 ++++++++++++++++++ 2 files changed, 394 insertions(+), 44 deletions(-) diff --git a/scripts/ci/verify-authenticode.ps1 b/scripts/ci/verify-authenticode.ps1 index 0f007084c36..593cc75426b 100644 --- a/scripts/ci/verify-authenticode.ps1 +++ b/scripts/ci/verify-authenticode.ps1 @@ -3,8 +3,9 @@ # the GitHub-hosted Windows image already carries. # # -Path PE files (or MSIs) to verify directly. -# -Msi An installer to verify, together with every .exe it installs. The MSI is -# expanded with "msiexec /a" (an administrative image, nothing is installed). +# -Msi An installer to verify, together with every .exe it installs. The MSI's own +# signature must pass before "msiexec /a" expands it (an administrative +# image, nothing is installed). # -RequireInnerExe File names that must be among the executables inside -Msi; a sweep that # finds fewer files than expected must not pass by inspecting nothing. # -MinimumInnerExe The fewest executables -Msi may contain (default 1). @@ -30,33 +31,12 @@ foreach ($candidate in $Path) { $targets.Add((Resolve-Path -LiteralPath $candidate).Path) } +$msiPath = "" if ($Msi) { $msiPath = (Resolve-Path -LiteralPath $Msi).Path - $targets.Add($msiPath) - $scratch = Join-Path ([System.IO.Path]::GetTempPath()) ("ocx-msi-" + [guid]::NewGuid().ToString("N")) - $image = Join-Path $scratch "image" - $log = Join-Path $scratch "administrative-image.log" - New-Item -ItemType Directory -Path $image -Force | Out-Null - $arguments = @("/a", "`"$msiPath`"", "/qn", "TARGETDIR=`"$image`"", "/l*v", "`"$log`"") - $process = Start-Process -FilePath "msiexec.exe" -ArgumentList $arguments -Wait -PassThru -NoNewWindow - if ($process.ExitCode -ne 0) { - if (Test-Path -LiteralPath $log) { Get-Content -LiteralPath $log -Tail 40 | Write-Host } - throw "msiexec /a could not expand $msiPath (exit code $($process.ExitCode))" - } - $inner = @(Get-ChildItem -LiteralPath $image -Recurse -File -Filter "*.exe") - Write-Host "The installer carries $($inner.Count) executable(s): $(($inner | ForEach-Object Name) -join ', ')" - if ($inner.Count -lt [Math]::Max(1, $MinimumInnerExe)) { - throw "Found $($inner.Count) executable(s) inside $msiPath, expected at least $MinimumInnerExe; the sweep would miss files." - } - foreach ($name in $RequireInnerExe) { - if (-not ($inner | Where-Object { $_.Name -ieq $name })) { - throw "$name is missing from the administrative image of $msiPath" - } - } - foreach ($file in $inner) { $targets.Add($file.FullName) } } -if ($targets.Count -eq 0) { +if ($targets.Count -eq 0 -and -not $msiPath) { throw "Nothing to verify: pass -Path and/or -Msi." } @@ -73,50 +53,94 @@ if ($Trust -eq "release") { $testStatuses = @("Valid", "UnknownError", "NotTrusted") $thumbprints = [System.Collections.Generic.HashSet[string]]::new() $failures = [System.Collections.Generic.List[string]]::new() +$verified = 0 -foreach ($target in $targets) { - $signature = Get-AuthenticodeSignature -LiteralPath $target +# Record the signature outcome for one file. A certificate is always required; everything else +# depends on the trust mode. Failures accumulate so one run reports every bad file. +function Test-AuthenticodeFile { + param([string]$Target) + $signature = Get-AuthenticodeSignature -LiteralPath $Target $certificate = $signature.SignerCertificate $status = [string]$signature.Status if (-not $certificate) { - $failures.Add("$target carries no Authenticode signature (status $status)") - continue + $failures.Add("$Target carries no Authenticode signature (status $status)") + return } $stamp = if ($signature.TimeStamperCertificate) { $signature.TimeStamperCertificate.Subject } else { "none" } - Write-Host "$target" + Write-Host "$Target" Write-Host " status: $status; signer: $($certificate.Subject); thumbprint: $($certificate.Thumbprint); timestamp: $stamp" [void]$thumbprints.Add($certificate.Thumbprint) if ($Trust -eq "release") { if ($status -ne "Valid") { - $failures.Add("$target is signed but not trusted (status ${status}: $($signature.StatusMessage))") + $failures.Add("$Target is signed but not trusted (status ${status}: $($signature.StatusMessage))") } if ($certificate.Subject -notmatch '(^|,\s*)CN="?SignPath Foundation"?(,|$)') { - $failures.Add("$target is signed by '$($certificate.Subject)', not SignPath Foundation") + $failures.Add("$Target is signed by '$($certificate.Subject)', not SignPath Foundation") } if (-not $signature.TimeStamperCertificate) { - $failures.Add("$target has no timestamp, so its signature expires with the certificate") + $failures.Add("$Target has no timestamp, so its signature expires with the certificate") } - & $signtool.FullName verify /pa /v "$target" | Write-Host + & $signtool.FullName verify /pa /v "$Target" | Write-Host if ($LASTEXITCODE -ne 0) { - $failures.Add("signtool verify /pa rejected $target (exit code $LASTEXITCODE)") + $failures.Add("signtool verify /pa rejected $Target (exit code $LASTEXITCODE)") } } elseif ($testStatuses -notcontains $status) { - $failures.Add("$target has an unusable test signature (status ${status}: $($signature.StatusMessage))") + $failures.Add("$Target has an unusable test signature (status ${status}: $($signature.StatusMessage))") + } + $script:verified += 1 +} + +# Stop before any further work once a recorded file failed. Callers see the throw and keep the +# unsigned original; an MSI that failed is never handed to msiexec below. +function Assert-AuthenticodeVerified { + if ($failures.Count -gt 0) { + foreach ($failure in $failures) { Write-Host "FAILED: $failure" } + # throw, not exit: a caller that runs this script and then another command would otherwise see + # the second command's exit code and carry on. No annotation here; the caller decides whether a + # failure is a warning (a release that ships unsigned) or an error (the signing test). + throw "Authenticode verification failed: $($failures -join '; ')" } } +if ($msiPath) { + # "msiexec /a" makes the Windows Installer engine parse the package, so the installer itself + # must pass the same signature contract as the files it carries before extraction may run. + Test-AuthenticodeFile $msiPath + Assert-AuthenticodeVerified + + $scratch = Join-Path ([System.IO.Path]::GetTempPath()) ("ocx-msi-" + [guid]::NewGuid().ToString("N")) + $image = Join-Path $scratch "image" + $log = Join-Path $scratch "administrative-image.log" + New-Item -ItemType Directory -Path $image -Force | Out-Null + $arguments = @("/a", "`"$msiPath`"", "/qn", "TARGETDIR=`"$image`"", "/l*v", "`"$log`"") + $process = Start-Process -FilePath "msiexec.exe" -ArgumentList $arguments -Wait -PassThru -NoNewWindow + if ($process.ExitCode -ne 0) { + if (Test-Path -LiteralPath $log) { Get-Content -LiteralPath $log -Tail 40 | Write-Host } + throw "msiexec /a could not expand $msiPath (exit code $($process.ExitCode))" + } + $inner = @(Get-ChildItem -LiteralPath $image -Recurse -File -Filter "*.exe") + Write-Host "The installer carries $($inner.Count) executable(s): $(($inner | ForEach-Object Name) -join ', ')" + if ($inner.Count -lt [Math]::Max(1, $MinimumInnerExe)) { + throw "Found $($inner.Count) executable(s) inside $msiPath, expected at least $MinimumInnerExe; the sweep would miss files." + } + foreach ($name in $RequireInnerExe) { + if (-not ($inner | Where-Object { $_.Name -ieq $name })) { + throw "$name is missing from the administrative image of $msiPath" + } + } + foreach ($file in $inner) { $targets.Add($file.FullName) } +} + +foreach ($target in $targets) { + Test-AuthenticodeFile $target +} + # One signing request per artifact signs every file with the same certificate. A second signer # means a file arrived already signed by somebody else, which the policy forbids re-signing. if ($thumbprints.Count -gt 1) { $failures.Add("Expected one signer across every file, found $($thumbprints.Count): $($thumbprints -join ', ')") } -if ($failures.Count -gt 0) { - foreach ($failure in $failures) { Write-Host "FAILED: $failure" } - # throw, not exit: a caller that runs this script and then another command would otherwise see - # the second command's exit code and carry on. No annotation here; the caller decides whether a - # failure is a warning (a release that ships unsigned) or an error (the signing test). - throw "Authenticode verification failed: $($failures -join '; ')" -} -Write-Host "Verified $($targets.Count) file(s) with $Trust trust." +Assert-AuthenticodeVerified +Write-Host "Verified $verified file(s) with $Trust trust." diff --git a/tests/ci-workflows/release-windows-signing.test.ts b/tests/ci-workflows/release-windows-signing.test.ts index b97c0387544..af91fbf1f9c 100644 --- a/tests/ci-workflows/release-windows-signing.test.ts +++ b/tests/ci-workflows/release-windows-signing.test.ts @@ -360,3 +360,329 @@ describe("signed artifact swap (scripts/ci/apply-signed-windows-artifact.ps1)", } }, PWSH_TIMEOUT_MS); }); + +/** + * The -Msi leg hands the installer to msiexec /a, which makes the Windows Installer engine parse + * the package. The MSI must therefore pass the same -Trust contract as every file it carries + * before extraction runs at all, and the executables inside the image then go through the same + * checks including the shared-signer rule. A PowerShell harness shadows Get-AuthenticodeSignature, + * Get-ChildItem (the SDK glob resolves to a fake .ps1 signtool) and Start-Process, so no real + * signing, certificate store, SDK or installer machinery runs on any platform; every faked call + * appends one line to an event log whose order the assertions check. The verifier scratch + * directory is redirected under the fixture, so administrative-image leftovers are cleaned up + * with the fixture instead of leaking into the real temp. + */ +describe("verify-authenticode MSI ordering (scripts/ci/verify-authenticode.ps1)", () => { + const pwsh = Bun.which("pwsh"); + const PWSH_TIMEOUT_MS = 30_000; + const SCRIPT = repoPath("scripts", "ci", "verify-authenticode.ps1"); + + const HARNESS = `param( + [Parameter(Mandatory = $true)][string]$ScriptPath, + [Parameter(Mandatory = $true)][string]$ConfigPath, + [string[]]$Path = @(), + [string]$Msi = "", + [string[]]$RequireInnerExe = @(), + [int]$MinimumInnerExe = 1, + [Parameter(Mandatory = $true)][string]$Trust +) +$ErrorActionPreference = "Stop" +$config = Get-Content -LiteralPath $ConfigPath -Raw | ConvertFrom-Json + +# The verifier script runs under Set-StrictMode -Version Latest; the fakes opt out so optional +# JSON fields may simply be absent. +function Get-AuthenticodeSignature { + param([string]$LiteralPath) + Set-StrictMode -Off + $leaf = Split-Path -Leaf $LiteralPath + Add-Content -LiteralPath $env:OCX_VERIFY_EVENTS -Value "signature|$leaf" + $entry = $config.signatures.$leaf + $certificate = $null + $stamper = $null + if ($entry -and $entry.signer) { + $certificate = [pscustomobject]@{ Subject = $entry.signer; Thumbprint = $entry.thumbprint } + } + if ($entry -and $entry.timestamp) { + $stamper = [pscustomobject]@{ Subject = $entry.timestamp } + } + $status = if ($entry -and $entry.status) { $entry.status } else { "NotSigned" } + $message = if ($entry -and $entry.statusMessage) { $entry.statusMessage } else { "" } + [pscustomobject]@{ Status = $status; StatusMessage = $message; SignerCertificate = $certificate; TimeStamperCertificate = $stamper } +} + +# The Windows SDK glob resolves to the fake signtool script; every other listing is real. +function Get-ChildItem { + [CmdletBinding()] + param([string]$Path, [string]$LiteralPath, [switch]$Recurse, [switch]$File, [string]$Filter) + Set-StrictMode -Off + if ($Path -like "*Windows Kits*signtool*") { + return [pscustomobject]@{ FullName = $config.signtoolPath } + } + Microsoft.PowerShell.Management\\Get-ChildItem @PSBoundParameters +} + +function Start-Process { + param([string]$FilePath, [string[]]$ArgumentList, [switch]$Wait, [switch]$PassThru, [switch]$NoNewWindow) + Set-StrictMode -Off + Add-Content -LiteralPath $env:OCX_VERIFY_EVENTS -Value ("process|" + $FilePath + "|nnw=" + $NoNewWindow.IsPresent + "|" + ($ArgumentList -join ' ')) + $exitCode = 0 + if ($null -ne $config.msiexecExitCode) { $exitCode = [int]$config.msiexecExitCode } + $target = "" + foreach ($argument in $ArgumentList) { + if ($argument -match '^TARGETDIR="?([^"]+)"?$') { $target = $Matches[1] } + } + New-Item -ItemType Directory -Path $target -Force | Out-Null + foreach ($name in @($config.innerExes)) { + New-Item -ItemType File -Path (Join-Path $target $name) -Force | Out-Null + } + if ($exitCode -ne 0) { + $log = Join-Path (Split-Path -Parent $target) "administrative-image.log" + Set-Content -LiteralPath $log -Value "fake msiexec failure log" + } + [pscustomobject]@{ ExitCode = $exitCode } +} + +try { + & $ScriptPath -Path $Path -Msi $Msi -RequireInnerExe $RequireInnerExe -MinimumInnerExe $MinimumInnerExe -Trust $Trust +} catch { + # Surface the thrown message as one literal stdout line: the default error formatter wraps + # long messages at the host console width, which would make substring assertions fragile. + [Console]::Out.WriteLine("error|" + $_.Exception.Message) + exit 1 +} +`; + + const SIGTOOL_PS1 = `Set-StrictMode -Off +$leaf = Split-Path -Leaf ($args | Select-Object -Last 1) +Add-Content -LiteralPath $env:OCX_VERIFY_EVENTS -Value "signtool|$leaf" +$code = 0 +if ($env:OCX_SIGNTOOL_EXIT) { $code = [int]$env:OCX_SIGNTOOL_EXIT } +$global:LASTEXITCODE = $code +`; + + type Sig = { status?: string; signer?: string; thumbprint?: string; timestamp?: string; statusMessage?: string }; + type Config = { signatures?: Record; innerExes?: string[]; msiexecExitCode?: number; signtoolPath?: string }; + + function fixture() { + const dir = mkdtempSync(join(tmpdir(), "ocx-verify-msi-")); + const scratch = join(dir, "tmp"); + mkdirSync(scratch, { recursive: true }); + const signtoolPath = join(dir, "fake-signtool.ps1"); + writeFileSync(signtoolPath, SIGTOOL_PS1); + writeFileSync(join(dir, "harness.ps1"), HARNESS); + const msi = join(dir, "installer.msi"); + const pathTarget = join(dir, "path-target.exe"); + writeFileSync(msi, "fake installer"); + writeFileSync(pathTarget, "fake path target"); + const eventsPath = join(dir, "events.log"); + const configPath = join(dir, "config.json"); + const run = (config: Config, args: string[], env: Record = {}) => { + writeFileSync(eventsPath, ""); + writeFileSync(configPath, JSON.stringify({ signtoolPath, ...config })); + const result = Bun.spawnSync([pwsh!, "-NoProfile", "-NonInteractive", "-File", join(dir, "harness.ps1"), + "-ScriptPath", SCRIPT, "-ConfigPath", configPath, ...args], { + env: { ...process.env, HOME: dir, TEMP: scratch, TMP: scratch, TMPDIR: scratch, OCX_VERIFY_EVENTS: eventsPath, ...env }, + }); + return { + exitCode: result.exitCode, + output: result.stdout.toString() + result.stderr.toString(), + events: readFileSync(eventsPath, "utf8").split("\n").map(line => line.trim()).filter(Boolean), + }; + }; + return { dir, msi, pathTarget, run }; + } + + const eventIndex = (events: string[], prefix: string) => events.findIndex(line => line.startsWith(prefix)); + const testSigned = { status: "NotTrusted", signer: "CN=ocx test", thumbprint: "AAAA" }; + const releaseSigned = { status: "Valid", signer: "CN=SignPath Foundation", thumbprint: "BBBB", timestamp: "CN=SignPath time" }; + + const rejectedOuter: Array<[string, Config, string, string]> = [ + ["an unsigned MSI under test trust", { innerExes: ["ocx.exe"] }, "test", "carries no Authenticode signature"], + ["an MSI with a mismatched hash under test trust", { signatures: { "installer.msi": { ...testSigned, status: "HashMismatch" } }, innerExes: ["ocx.exe"] }, "test", "unusable test signature"], + ["an unsigned MSI under release trust", { innerExes: ["ocx.exe"] }, "release", "carries no Authenticode signature"], + ["an MSI the chain cannot validate under release trust", { signatures: { "installer.msi": { ...releaseSigned, status: "NotTrusted" } }, innerExes: ["ocx.exe"] }, "release", "signed but not trusted"], + ["an MSI from the wrong signer under release trust", { signatures: { "installer.msi": { ...releaseSigned, signer: "CN=somebody else" } }, innerExes: ["ocx.exe"] }, "release", "not SignPath Foundation"], + ["an MSI with no timestamp under release trust", { signatures: { "installer.msi": { status: "Valid", signer: "CN=SignPath Foundation", thumbprint: "BBBB" } }, innerExes: ["ocx.exe"] }, "release", "has no timestamp"], + ]; + for (const [label, config, trust, fragment] of rejectedOuter) { + test.skipIf(!pwsh)(label + " fails before msiexec ever sees the file", () => { + const f = fixture(); + try { + const result = f.run(config, ["-Msi", f.msi, "-Trust", trust]); + expect(result.exitCode).not.toBe(0); + expect(result.output).toContain(fragment); + expect(result.events.filter(line => line.startsWith("signature|"))).toEqual(["signature|installer.msi"]); + expect(result.events.some(line => line.startsWith("process|"))).toBe(false); + } finally { removeTreeWithRetry(f.dir); } + }, PWSH_TIMEOUT_MS); + } + + test.skipIf(!pwsh)("an MSI signtool rejects under release trust is never extracted", () => { + const f = fixture(); + try { + const result = f.run({ + signatures: { "installer.msi": releaseSigned, "ocx.exe": releaseSigned }, + innerExes: ["ocx.exe"], + }, ["-Msi", f.msi, "-Trust", "release"], { OCX_SIGNTOOL_EXIT: "7" }); + expect(result.exitCode).not.toBe(0); + expect(result.output).toContain("signtool verify /pa rejected"); + expect(result.events).toContain("signtool|installer.msi"); + expect(result.events.filter(line => line.startsWith("signature|"))).toEqual(["signature|installer.msi"]); + expect(result.events.some(line => line.startsWith("process|"))).toBe(false); + } finally { removeTreeWithRetry(f.dir); } + }, PWSH_TIMEOUT_MS); + + test.skipIf(!pwsh)("an accepted MSI is expanded before its inner executables are verified (test trust)", () => { + const f = fixture(); + try { + const result = f.run({ + signatures: { "installer.msi": testSigned, "ocx.exe": testSigned, "helper.exe": testSigned }, + innerExes: ["ocx.exe", "subdir/helper.exe"], + }, ["-Msi", f.msi, "-Trust", "test"]); + expect(result.exitCode, result.output).toBe(0); + expect(result.output).toContain("Verified 3 file(s) with test trust."); + const outer = eventIndex(result.events, "signature|installer.msi"); + const extract = eventIndex(result.events, "process|msiexec.exe"); + expect(outer).toBeGreaterThanOrEqual(0); + expect(extract).toBeGreaterThan(outer); + expect(eventIndex(result.events, "signature|ocx.exe")).toBeGreaterThan(extract); + expect(eventIndex(result.events, "signature|helper.exe")).toBeGreaterThan(extract); + expect(result.events[extract]).toContain("nnw=True"); + } finally { removeTreeWithRetry(f.dir); } + }, PWSH_TIMEOUT_MS); + + test.skipIf(!pwsh)("an accepted MSI is expanded only after signtool passes it (release trust)", () => { + const f = fixture(); + try { + const result = f.run({ + signatures: { "installer.msi": releaseSigned, "ocx.exe": releaseSigned }, + innerExes: ["ocx.exe"], + }, ["-Msi", f.msi, "-Trust", "release"]); + expect(result.exitCode, result.output).toBe(0); + expect(result.output).toContain("Verified 2 file(s) with release trust."); + const extract = eventIndex(result.events, "process|msiexec.exe"); + expect(eventIndex(result.events, "signature|installer.msi")).toBe(0); + expect(eventIndex(result.events, "signtool|installer.msi")).toBe(1); + expect(extract).toBe(2); + expect(eventIndex(result.events, "signature|ocx.exe")).toBeGreaterThan(extract); + expect(eventIndex(result.events, "signtool|ocx.exe")).toBeGreaterThan(extract); + } finally { removeTreeWithRetry(f.dir); } + }, PWSH_TIMEOUT_MS); + + test.skipIf(!pwsh)("an inner executable from the wrong release signer fails after extraction", () => { + const f = fixture(); + try { + const result = f.run({ + signatures: { + "installer.msi": releaseSigned, + "ocx.exe": { ...releaseSigned, signer: "CN=somebody else" }, + }, + innerExes: ["ocx.exe"], + }, ["-Msi", f.msi, "-Trust", "release"]); + expect(result.exitCode).not.toBe(0); + expect(result.output).toContain("not SignPath Foundation"); + const extract = eventIndex(result.events, "process|msiexec.exe"); + expect(extract).toBeGreaterThanOrEqual(0); + expect(result.events.filter(line => line.startsWith("signature|"))).toEqual(["signature|installer.msi", "signature|ocx.exe"]); + expect(eventIndex(result.events, "signature|ocx.exe")).toBeGreaterThan(extract); + } finally { removeTreeWithRetry(f.dir); } + }, PWSH_TIMEOUT_MS); + + test.skipIf(!pwsh)("unsigned executables inside a passing MSI still fail the sweep", () => { + const f = fixture(); + try { + const result = f.run({ + signatures: { "installer.msi": testSigned }, + innerExes: ["ocx.exe", "helper.exe"], + }, ["-Msi", f.msi, "-Trust", "test"]); + expect(result.exitCode).not.toBe(0); + expect(result.output).toContain("ocx.exe carries no Authenticode signature"); + expect(result.output).toContain("helper.exe carries no Authenticode signature"); + expect(eventIndex(result.events, "process|msiexec.exe")).toBeGreaterThan(0); + } finally { removeTreeWithRetry(f.dir); } + }, PWSH_TIMEOUT_MS); + + test.skipIf(!pwsh)("an inner executable from a second signer fails the shared-signer rule", () => { + const f = fixture(); + try { + const result = f.run({ + signatures: { "installer.msi": testSigned, "ocx.exe": { ...testSigned, thumbprint: "CCCC" } }, + innerExes: ["ocx.exe"], + }, ["-Msi", f.msi, "-Trust", "test"]); + expect(result.exitCode).not.toBe(0); + expect(result.output).toContain("Expected one signer across every file"); + } finally { removeTreeWithRetry(f.dir); } + }, PWSH_TIMEOUT_MS); + + test.skipIf(!pwsh)("an inner executable with an unusable test signature fails", () => { + const f = fixture(); + try { + const result = f.run({ + signatures: { "installer.msi": testSigned, "ocx.exe": { ...testSigned, status: "HashMismatch" } }, + innerExes: ["ocx.exe"], + }, ["-Msi", f.msi, "-Trust", "test"]); + expect(result.exitCode).not.toBe(0); + expect(result.output).toContain("unusable test signature"); + } finally { removeTreeWithRetry(f.dir); } + }, PWSH_TIMEOUT_MS); + + test.skipIf(!pwsh)("-Path targets are verified directly without extraction", () => { + const f = fixture(); + try { + const passing = f.run({ signatures: { "path-target.exe": testSigned } }, ["-Path", f.pathTarget, "-Trust", "test"]); + expect(passing.exitCode, passing.output).toBe(0); + expect(passing.output).toContain("Verified 1 file(s) with test trust."); + expect(passing.events).toEqual(["signature|path-target.exe"]); + const failing = f.run({}, ["-Path", f.pathTarget, "-Trust", "test"]); + expect(failing.exitCode).not.toBe(0); + expect(failing.output).toContain("path-target.exe carries no Authenticode signature"); + } finally { removeTreeWithRetry(f.dir); } + }, PWSH_TIMEOUT_MS); + + test.skipIf(!pwsh)("-Path and -Msi inputs are all verified in one run", () => { + const f = fixture(); + try { + const result = f.run({ + signatures: { "installer.msi": testSigned, "path-target.exe": testSigned, "ocx.exe": testSigned }, + innerExes: ["ocx.exe"], + }, ["-Path", f.pathTarget, "-Msi", f.msi, "-Trust", "test"]); + expect(result.exitCode, result.output).toBe(0); + expect(result.output).toContain("Verified 3 file(s) with test trust."); + const extract = eventIndex(result.events, "process|msiexec.exe"); + expect(eventIndex(result.events, "signature|installer.msi")).toBe(0); + expect(eventIndex(result.events, "signature|path-target.exe")).toBeGreaterThan(extract); + expect(eventIndex(result.events, "signature|ocx.exe")).toBeGreaterThan(extract); + } finally { removeTreeWithRetry(f.dir); } + }, PWSH_TIMEOUT_MS); + + test.skipIf(!pwsh)("the required-name and minimum-count guards still gate the sweep", () => { + const f = fixture(); + try { + const config: Config = { signatures: { "installer.msi": testSigned, "ocx.exe": testSigned }, innerExes: ["ocx.exe"] }; + const missing = f.run(config, ["-Msi", f.msi, "-RequireInnerExe", "daemon.exe", "-Trust", "test"]); + expect(missing.exitCode).not.toBe(0); + expect(missing.output).toContain("daemon.exe is missing from the administrative image"); + expect(missing.events.filter(line => line.startsWith("signature|"))).toEqual(["signature|installer.msi"]); + const few = f.run(config, ["-Msi", f.msi, "-MinimumInnerExe", "3", "-Trust", "test"]); + expect(few.exitCode).not.toBe(0); + expect(few.output).toContain("expected at least 3"); + expect(few.events.filter(line => line.startsWith("signature|"))).toEqual(["signature|installer.msi"]); + } finally { removeTreeWithRetry(f.dir); } + }, PWSH_TIMEOUT_MS); + + test.skipIf(!pwsh)("a failed extraction reports the msiexec log and stops", () => { + const f = fixture(); + try { + const result = f.run({ + signatures: { "installer.msi": testSigned }, + innerExes: ["ocx.exe"], + msiexecExitCode: 5, + }, ["-Msi", f.msi, "-Trust", "test"]); + expect(result.exitCode).not.toBe(0); + expect(result.output).toContain("fake msiexec failure log"); + expect(result.output).toContain("could not expand"); + expect(eventIndex(result.events, "process|msiexec.exe")).toBeGreaterThan(0); + expect(result.events.filter(line => line.startsWith("signature|"))).toEqual(["signature|installer.msi"]); + } finally { removeTreeWithRetry(f.dir); } + }, PWSH_TIMEOUT_MS); +}); From dc49f32f5bcd182781285c3443ec3f0e9e206190 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Mon, 26 Oct 2026 10:25:13 +0000 Subject: [PATCH 078/101] fix(service): preserve Windows probe support across Node and Bun (#7722) * fix(service): harden Windows supervision probe * Restore the Node lane's Windows system-directory resolver resolveWindowsSystemDirectory previously required process.versions.bun, so under Node the guard threw before execute() and the live-Desktop veto probe could never reach PowerShell: every inspection collapsed to a plain unknown, which is intentionally nonblocking. Node now derives the directory from process.report.sharedObjects instead: exactly one well-formed kernel32.dll and ntdll.dll namesake must share one case-insensitive parent, duplicates/malformed/truncated/missing namesakes reject, and the candidate must contain both DLLs and the v1.0 powershell.exe. Only .sharedObjects is read (never the env-bearing report), a resolved directory is memoized for the process lifetime while failures keep their retry-on-next-call contract, and the Bun lane keeps its FFI resolver behind a try/finally lib.close(). Unavailable or ambiguous reports remain the bounded unknown residual; WoW64/ARM64 enumeration forms stay unverified. windowsProcessScript also imports Microsoft.PowerShell.Utility before CimCmdlets: the CimCmdlets manifest calls Set-Alias during its own import, which PSModuleAutoLoadingPreference 'None' cannot supply. * tests: emit path-free codexRuntime diagnostics on settings assertion failure (PR 7722) Diagnostic-only candidate for the macOS control failure where codexRuntime.version arrived null instead of 0.133.0 while runtime, config-routes, and helper blobs were identical to a passing control. The case now keeps the resolveCodexRuntimeAsync result, times it, peeks the process memo right after resolve, and only on failure appends a path-free snapshot (runtime source/version, env-candidate hit, classified probe-failure reasons, cache kind/epoch/identity, codexRuntimeStateEpoch, and the projected codexRuntime without paths) to the assertion error. Success output is unchanged; no commands, env, or exec output are emitted; probe reasons are capped to the fixed classifier set plus a coarse deadline bucket. * tests: simplify codexRuntime failure diagnostics to an assertion message (PR 7722) Review follow-up to c47a2661: drop the catch/rethrow rewrite and carry the diagnostic on the existing version expect's message slot (established expect(actual, message) convention). The test body returns to its original inline type and try/finally shape; resolve timing, the resolved result, and a read-only cache peek are captured as consts and rendered only on failure. Diagnostics now report resolveMs, resolved source/version with classified probe-failure reason buckets, cache kind/epoch, and projected source/version/warningPresent - no valueIdentity, warning text, commands, env, or exec output. * docs(structure): document Windows supervision system-directory acquisition contract Explain the runtime-specific system-directory resolvers the default Windows process reader now requires before its bounded Win32_Process snapshot: Bun GetSystemDirectoryW via bun:ffi, Node process.report sharedObjects enumeration with one well-formed namesake per KnownDLL. Records why caller-supplied environment paths and the fixed fallback are no longer accepted, the bounded unknown residual (missing, ambiguous, duplicated, truncated or unusable evidence never proves absence), success-only directory caching with fresh per-call process snapshots, the minimal PowerShell environment and explicit Utility-then-CimCmdlets module loading. Keeps the Node >=18 import contract explicit, cites the Node report API version basis, and scopes positive evidence to Node 24.18.0 and Bun 1.4.4 on Windows x64 with WoW64, ARM64 and Desktop-positive paths unverified. Addresses Ingwannu review documentation request; no code or test changes. * docs(structure): clarify Windows probe budget and existence-check wording The six-second budget and three-second acquisition timeout bound scheduling, not the complete inspection: synchronous system-directory resolution runs before the PowerShell subprocess and carries no separate timeout. The candidate check is an F_OK existence check, not a signature or type guarantee. Review follow-up wording only. * docs(structure): split Desktop supervision contract into ops doc Move the Runtime ownership section and the new Windows acquisition contract out of desktop-shell.md into structure/ops/desktop-supervision.md, which now owns the supervision module contract. desktop-shell keeps both heading anchors with concise forwarding links so existing inbound anchors still resolve; service-and-sidecars links the new authority for probe acquisition and residual limits. Keeps the 6 s scheduling-budget / 250 ms floor / 3 s subprocess-timeout nuance, F_OK existence-check wording, Node >=18 import contract, Node report API version-basis links and the bounded unknown residual. INDEX.md regeneration and structure:check are queued for the sole-runtime Bun 1.4.4 slot. * docs(structure): correct desktop supervision module ownership scope The module owns the platform supervision probe and the supervision latch; durable ownership and consent stay with the owners the document describes. * docs: regenerate structure index * docs: remove trailing blank line from supervision reference * test(clients): read the moved desktop acceptance contract from ops/desktop-supervision.md * docs(structure): move desktop packaging notes and complete supervision map The WidgetKit appex and prepare-sidecar paragraphs at the tail of ops/desktop-supervision.md are packaging contracts unrelated to supervision; move them into desktop-shell.md beside the existing Widget snapshot and packaged keyring sections. The doc's manifest documents list only named the two supervision modules, so expand it to the latch consumers and shell-side ownership modules the doc describes, name those areas in the prose, and regenerate INDEX.md. --------- Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> --- src/service/desktop-supervision.d.mts | 11 +- src/service/desktop-supervision.mjs | 103 ++++++++-- structure/INDEX.md | 22 ++- structure/desktop-shell.md | 148 +++------------ structure/manifest.json | 27 +++ structure/ops/desktop-supervision.md | 177 ++++++++++++++++++ structure/ops/service-and-sidecars.md | 5 +- .../clients/desktop-install-identity.test.ts | 22 ++- tests/config/settings-stream-mode.test.ts | 41 +++- ...ervice-desktop-supervision-windows.test.ts | 105 ++++++++++- 10 files changed, 497 insertions(+), 164 deletions(-) create mode 100644 structure/ops/desktop-supervision.md diff --git a/src/service/desktop-supervision.d.mts b/src/service/desktop-supervision.d.mts index c62b08c0acc..c7e8d3cd353 100644 --- a/src/service/desktop-supervision.d.mts +++ b/src/service/desktop-supervision.d.mts @@ -27,18 +27,18 @@ export interface WindowsProcessReadOptions { /** Called with the parent image before row validation, so partial Desktop evidence keeps its veto. */ onExecutable?: (path: string) => void; timeoutMs?: number; - env?: NodeJS.ProcessEnv; + resolveSystemDirectory?: () => string; } export interface DesktopSupervisionDeps { targetPid?: number; platform?: NodeJS.Platform; - run?: (command: string, args: string[], timeoutMs?: number) => string; + run?: (command: string, args: string[], timeoutMs?: number, env?: NodeJS.ProcessEnv) => string; proc?: ProcReader; readPid?: () => number | null; readRuntimePortPid?: () => number | null; windowsProcesses?: (pid: number, options: Pick) => WindowsProcessPair | null; - env?: NodeJS.ProcessEnv; + resolveWindowsSystemDirectory?: () => string; now?: () => number; } @@ -46,7 +46,10 @@ export function desktopSupervisionPaths(): { pid: string; runtimePort: string }; export const procfs: ProcReader; export function processIdentity(pid: number, execute?: DesktopSupervisionDeps["run"], onExecutable?: (path: string) => void): { parent: number; executable: string } | null; -export function windowsPowerShellPath(env?: NodeJS.ProcessEnv): string; +export function windowsPowerShellPath(resolveSystemDirectory?: () => string): string; +/** Pure consistency check over loaded-module paths; throws unless exactly one well-formed + * namesake per KnownDLL shares one parent directory. Exported for tests. */ +export function windowsSystemDirectoryFromSharedObjects(loaded: unknown): string; export function windowsProcessPair(pid: number, execute?: DesktopSupervisionDeps["run"], options?: WindowsProcessReadOptions): WindowsProcessPair; export function inspectDesktopSupervision(deps?: DesktopSupervisionDeps): SupervisionEvidence; export function createSupervisionLatch(): { observe(evidence: SupervisionEvidence): boolean }; diff --git a/src/service/desktop-supervision.mjs b/src/service/desktop-supervision.mjs index a147fd7c2bd..562303d6c02 100644 --- a/src/service/desktop-supervision.mjs +++ b/src/service/desktop-supervision.mjs @@ -1,12 +1,14 @@ import { execFileSync } from "node:child_process"; import { accessSync, constants, readFileSync, readlinkSync, realpathSync } from "node:fs"; +import { createRequire } from "node:module"; import { homedir } from "node:os"; import { basename, dirname, join, resolve, win32 } from "node:path"; // Node >=18 also imports this module from bin/ocx.mjs; do not import TS or Bun APIs. -function run(command, args, timeout = 750) { +function run(command, args, timeout = 750, env) { return execFileSync(command, args, { - encoding: "utf8", timeout, maxBuffer: 128 * 1024, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, + encoding: "utf8", timeout, env, maxBuffer: 128 * 1024, + stdio: ["ignore", "pipe", "pipe"], windowsHide: true, }).trim(); } @@ -80,22 +82,95 @@ const WINDOWS_ACQUISITION_TIMEOUT_MS = 3_000; const WINDOWS_MIN_ACQUISITION_MS = 250; const WINDOWS_DESKTOP_IMAGE = "opencodex-desktop.exe"; -/** Mirrors src/update/npm-invocation.mjs: plain ESM cannot reach the FFI system-directory resolver. */ -export function windowsPowerShellPath(env = process.env) { - const root = env.SystemRoot ?? env.windir; - const base = root && /^[A-Za-z]:\\/.test(root.replaceAll("/", "\\")) ? root : "C:\\Windows"; - return win32.join(base, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"); +/** Bun resolves the system directory via FFI; the Node lane derives it from loaded KnownDLL paths. */ +export function windowsPowerShellPath(resolveSystemDirectory = resolveWindowsSystemDirectory) { + const system = resolveSystemDirectory(); + return win32.join(system, "WindowsPowerShell", "v1.0", "powershell.exe"); +} + +const WINDOWS_REPORT_PATH_LIMIT = 260; // Node's module-name buffer is WCHAR[MAX_PATH] (src/debug_utils.cc). +const WINDOWS_KNOWN_DLLS = ["kernel32.dll", "ntdll.dll"]; + +// A resolved system directory is stable for the process lifetime; only success is +// memoized, so failures keep the existing retry-on-next-call recovery contract. +let windowsSystemDirectoryCache = null; + +function resolveWindowsSystemDirectory() { + if (process.platform !== "win32") throw new Error("trusted Windows resolver unavailable"); + if (windowsSystemDirectoryCache !== null) return windowsSystemDirectoryCache; + const directory = process.versions.bun ? resolveWindowsSystemDirectoryViaFfi() : resolveWindowsSystemDirectoryViaSharedObjects(); + return (windowsSystemDirectoryCache = directory); +} + +function resolveWindowsSystemDirectoryViaFfi() { + const { dlopen, ptr } = createRequire(import.meta.url)("bun:ffi"); + const lib = dlopen("kernel32.dll", { GetSystemDirectoryW: { args: ["ptr", "u32"], returns: "u32" } }); + try { + let size = 260; + for (let attempt = 0; attempt < 4; attempt++) { + const buffer = new Uint16Array(size); + const written = lib.symbols.GetSystemDirectoryW(ptr(buffer.buffer), size); + if (written === 0) throw new Error("GetSystemDirectoryW failed"); + if (written >= size) { size = written + 1; continue; } + return String.fromCharCode(...buffer.subarray(0, written)).replace(/[/\\]+$/, ""); + } + throw new Error("Windows system directory is too long"); + } finally { + lib.close(); + } +} + +/** Pure consistency check over loaded-module paths — no env, no filesystem. Exported for tests. */ +export function windowsSystemDirectoryFromSharedObjects(loaded) { + if (!Array.isArray(loaded)) throw new Error("trusted Windows resolver unavailable"); + const parents = new Map(); // lowercased -> first-seen casing (kernel32 is processed first) + for (const known of WINDOWS_KNOWN_DLLS) { + // Collect every raw namesake first: a malformed or truncated namesake must + // cause rejection, not disappear behind a format filter. + const namesakes = loaded.filter(path => typeof path === "string" && win32.basename(path).toLowerCase() === known); + for (const candidate of namesakes) { + if (candidate.length >= WINDOWS_REPORT_PATH_LIMIT || !/^[A-Za-z]:[\\/]/.test(candidate)) + throw new Error("trusted Windows resolver unavailable"); + } + if (namesakes.length !== 1) throw new Error("trusted Windows resolver unavailable"); + const parent = win32.dirname(win32.normalize(namesakes[0])); + if (!parents.has(parent.toLowerCase())) parents.set(parent.toLowerCase(), parent); + } + if (parents.size !== 1) throw new Error("trusted Windows resolver unavailable"); + return [...parents.values()][0]; +} + +// The report object carries environment secrets: read .sharedObjects only — never +// log, serialize, store or pass it on. Existence checks stay outside the pure validator. +// An unavailable or ambiguous report stays the bounded unknown residual. +function resolveWindowsSystemDirectoryViaSharedObjects() { + const loaded = process.report?.getReport?.()?.sharedObjects; + const directory = windowsSystemDirectoryFromSharedObjects(loaded); + for (const required of [...WINDOWS_KNOWN_DLLS, win32.join("WindowsPowerShell", "v1.0", "powershell.exe")]) { + try { accessSync(win32.join(directory, required), constants.F_OK); } + catch { throw new Error("trusted Windows resolver unavailable"); } + } + return directory; +} + +function windowsPowerShellEnv(systemDirectory) { + const root = win32.dirname(systemDirectory); + return { SystemRoot: root, WINDIR: root, PATH: systemDirectory, PATHEXT: ".COM;.EXE;.BAT;.CMD" }; } function windowsProcessScript(pid) { // pid is a validated safe integer; it only ever reaches a numeric WQL filter. return [ "$ErrorActionPreference='Stop'", + "$PSModuleAutoLoadingPreference='None'", + // Utility loads first: the CimCmdlets manifest needs Set-Alias, which autoload 'None' cannot supply. + "Import-Module ([IO.Path]::Combine($PSHOME,'Modules','Microsoft.PowerShell.Utility','Microsoft.PowerShell.Utility.psd1')) -Force", + "Import-Module ([IO.Path]::Combine($PSHOME,'Modules','CimCmdlets','CimCmdlets.psd1')) -Force", "function Row($p) { if ($null -eq $p) { return $null }; [ordered]@{ pid=[string]$p.ProcessId; parent=[string]$p.ParentProcessId; exe=$p.ExecutablePath; session=[string]$p.SessionId; created=$(if ($p.CreationDate) { [string]$p.CreationDate.ToUniversalTime().Ticks } else { $null }) } }", - `$c=Get-CimInstance -ClassName Win32_Process -Filter 'ProcessId=${pid}'`, + `$c=CimCmdlets\\Get-CimInstance -ClassName Win32_Process -Filter 'ProcessId=${pid}'`, "$p=$null", - "if ($c -and [uint32]$c.ParentProcessId -gt 0) { $p=Get-CimInstance -ClassName Win32_Process -Filter ('ProcessId=' + [uint32]$c.ParentProcessId) }", - "[ordered]@{ child=(Row $c); parent=(Row $p) } | ConvertTo-Json -Compress", + "if ($c -and [uint32]$c.ParentProcessId -gt 0) { $p=CimCmdlets\\Get-CimInstance -ClassName Win32_Process -Filter ('ProcessId=' + [uint32]$c.ParentProcessId) }", + "[ordered]@{ child=(Row $c); parent=(Row $p) } | Microsoft.PowerShell.Utility\\ConvertTo-Json -Compress", ].join("; "); } @@ -120,9 +195,11 @@ function windowsRow(row) { /** One bounded Win32_Process read of a process and its parent. */ export function windowsProcessPair(pid, execute = run, options = {}) { if (!validPid(pid)) throw new Error("invalid pid"); - const output = execute(windowsPowerShellPath(options.env), [ + const resolveSystemDirectory = options.resolveSystemDirectory ?? resolveWindowsSystemDirectory; + const systemDirectory = resolveSystemDirectory(); + const output = execute(windowsPowerShellPath(() => systemDirectory), [ "-NoProfile", "-NoLogo", "-NonInteractive", "-Command", windowsProcessScript(pid), - ], options.timeoutMs ?? WINDOWS_ACQUISITION_TIMEOUT_MS); + ], options.timeoutMs ?? WINDOWS_ACQUISITION_TIMEOUT_MS, windowsPowerShellEnv(systemDirectory)); const raw = JSON.parse(output); if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("invalid process pair"); // A Desktop parent seen before a later field fails validation must keep its veto. @@ -154,7 +231,7 @@ export function inspectDesktopSupervision(deps = {}) { const now = deps.now ?? Date.now; const deadline = now() + WINDOWS_INSPECTION_BUDGET_MS; const readWindowsPair = deps.windowsProcesses - ?? ((pid, options) => windowsProcessPair(pid, execute, { ...options, env: deps.env })); + ?? ((pid, options) => windowsProcessPair(pid, execute, { ...options, resolveSystemDirectory: deps.resolveWindowsSystemDirectory })); function identity(pid, parent = false) { if (platform === "darwin") return processIdentity(pid, execute, parent ? seen : undefined); const executable = proc.exe(pid); diff --git a/structure/INDEX.md b/structure/INDEX.md index 99683ebea5d..b3b60666d11 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -137,6 +137,7 @@ Background service, docs, release, and design discipline. | [`desktop-terminal-command.md`](desktop-terminal-command.md) | Desktop-owned ocx command on PATH: record and journal, POSIX shim and shell blocks, Windows user Path, private-path ACL policy, and the local Terminal command page. | | [`desktop-start-at-login.md`](desktop-start-at-login.md) | macOS tray Start at Login judgment shared with Startup safety, its parity fixture, click rules, and the guarded launch-origin migration. | | [`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | Service install/repair, platform launchers, tray, and sidecar processes. | +| [`ops/desktop-supervision.md`](ops/desktop-supervision.md) | Durable Desktop ownership read, takeover-consent handling, live-supervision evidence, the Windows probe's trusted system-directory acquisition, and the supervision latch blocking contract consumed by service and update guards. | | [`ops/service-logging.md`](ops/service-logging.md) | Service-only console timestamps and bounded copy-truncate log retention. | | [`ops/service-health-recovery.md`](ops/service-health-recovery.md) | Opt-in sentinel that turns a hung but listening managed service proxy into an exit its manager restarts. | | [`ops/update-supervised-recovery.md`](ops/update-supervised-recovery.md) | Manager-first recovery after a failed post-update service refresh, and the non-zero unsupervised fallback. | @@ -156,13 +157,20 @@ A source area can be described by more than one doc, because these docs are orga | `.github/` | [`ops/docs-and-release.md`](ops/docs-and-release.md) | | `app/` | [`overview.md`](overview.md)
[`companion.md`](companion.md) | | `bin/` | [`runtime.md`](runtime.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | +| `bin/ocx.mjs` | [`ops/desktop-supervision.md`](ops/desktop-supervision.md) | | `desktop/` | [`desktop-shell.md`](desktop-shell.md)
[`companion.md`](companion.md) | +| `desktop/src-tauri/src/claim.rs` | [`ops/desktop-supervision.md`](ops/desktop-supervision.md) | | `desktop/src-tauri/src/cli_command.rs` | [`desktop-terminal-command.md`](desktop-terminal-command.md) | | `desktop/src-tauri/src/cli_command_posix.rs` | [`desktop-terminal-command.md`](desktop-terminal-command.md) | | `desktop/src-tauri/src/cli_command_record.rs` | [`desktop-terminal-command.md`](desktop-terminal-command.md) | | `desktop/src-tauri/src/cli_command_windows.rs` | [`desktop-terminal-command.md`](desktop-terminal-command.md) | -| `desktop/src-tauri/src/first_run.rs` | [`desktop-start-at-login.md`](desktop-start-at-login.md) | +| `desktop/src-tauri/src/first_run.rs` | [`desktop-start-at-login.md`](desktop-start-at-login.md)
[`ops/desktop-supervision.md`](ops/desktop-supervision.md) | +| `desktop/src-tauri/src/identity.rs` | [`ops/desktop-supervision.md`](ops/desktop-supervision.md) | | `desktop/src-tauri/src/login_registration.rs` | [`desktop-start-at-login.md`](desktop-start-at-login.md) | +| `desktop/src-tauri/src/ownership.rs` | [`ops/desktop-supervision.md`](ops/desktop-supervision.md) | +| `desktop/src-tauri/src/resolve.rs` | [`ops/desktop-supervision.md`](ops/desktop-supervision.md) | +| `desktop/src-tauri/src/runtime_stop.rs` | [`ops/desktop-supervision.md`](ops/desktop-supervision.md) | +| `desktop/src-tauri/src/startup.rs` | [`ops/desktop-supervision.md`](ops/desktop-supervision.md) | | `desktop/src-tauri/src/tray_login.rs` | [`desktop-start-at-login.md`](desktop-start-at-login.md) | | `desktop/ui/cli.html` | [`desktop-terminal-command.md`](desktop-terminal-command.md) | | `desktop/ui/cli.js` | [`desktop-terminal-command.md`](desktop-terminal-command.md) | @@ -198,9 +206,11 @@ A source area can be described by more than one doc, because these docs are orga | `src/cli/` | [`runtime.md`](runtime.md)
[`config.md`](config.md)
[`remote-messaging.md`](remote-messaging.md)
[`local-messaging.md`](local-messaging.md)
[`cli-management.md`](cli-management.md)
[`provider-completion-diagnostic.md`](provider-completion-diagnostic.md)
[`fallback-policy.md`](fallback-policy.md)
[`clients/integrations.md`](clients/integrations.md)
[`clients/codex-desktop.md`](clients/codex-desktop.md)
[`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`clients/hermes.md`](clients/hermes.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `src/cli/account-preservation.ts` | [`providers/openai-signed-in-preservation.md`](providers/openai-signed-in-preservation.md) | | `src/cli/codex-provider.ts` | [`codex-provider-routing.md`](codex-provider-routing.md) | +| `src/cli/desktop-runtime-guidance.ts` | [`ops/desktop-supervision.md`](ops/desktop-supervision.md) | | `src/cli/link-apply-restart.ts` | [`remote-link.md`](remote-link.md) | | `src/cli/models-runtime.ts` | [`pricing.md`](pricing.md) | | `src/cli/status-codex-settings.ts` | [`remote-link.md`](remote-link.md) | +| `src/cli/update-restart.ts` | [`ops/desktop-supervision.md`](ops/desktop-supervision.md) | | `src/client/` | [`runtime.md`](runtime.md)
[`fallback-policy.md`](fallback-policy.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md) | | `src/client/link-home.ts` | [`remote-link.md`](remote-link.md) | | `src/client/link-parity-reporter.ts` | [`remote-link.md`](remote-link.md) | @@ -312,7 +322,11 @@ A source area can be described by more than one doc, because these docs are orga | `src/service-manager-probe.ts` | [`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | | `src/service.ts` | [`runtime.md`](runtime.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `src/service/` | [`runtime.md`](runtime.md) | -| `src/service/desktop-startup.ts` | [`desktop-start-at-login.md`](desktop-start-at-login.md) | +| `src/service/cli.ts` | [`ops/desktop-supervision.md`](ops/desktop-supervision.md) | +| `src/service/desktop-command-guard.ts` | [`ops/desktop-supervision.md`](ops/desktop-supervision.md) | +| `src/service/desktop-startup.ts` | [`desktop-start-at-login.md`](desktop-start-at-login.md)
[`ops/desktop-supervision.md`](ops/desktop-supervision.md) | +| `src/service/desktop-supervision.d.mts` | [`ops/desktop-supervision.md`](ops/desktop-supervision.md) | +| `src/service/desktop-supervision.mjs` | [`ops/desktop-supervision.md`](ops/desktop-supervision.md) | | `src/service/health-sentinel-launch.ts` | [`ops/service-health-recovery.md`](ops/service-health-recovery.md) | | `src/service/health-sentinel-linux.ts` | [`ops/service-health-recovery.md`](ops/service-health-recovery.md) | | `src/service/health-sentinel-main.ts` | [`ops/service-health-recovery.md`](ops/service-health-recovery.md) | @@ -320,6 +334,8 @@ A source area can be described by more than one doc, because these docs are orga | `src/service/health-sentinel-probe.ts` | [`ops/service-health-recovery.md`](ops/service-health-recovery.md) | | `src/service/health-sentinel-windows.ts` | [`ops/service-health-recovery.md`](ops/service-health-recovery.md) | | `src/service/logging.ts` | [`ops/service-logging.md`](ops/service-logging.md) | +| `src/service/orchestration.ts` | [`ops/desktop-supervision.md`](ops/desktop-supervision.md) | +| `src/service/repair.ts` | [`ops/desktop-supervision.md`](ops/desktop-supervision.md) | | `src/sidecar/` | [`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | | `src/sidecar/anthropic-binding.ts` | [`providers/anthropic-account-pool.md`](providers/anthropic-account-pool.md) | | `src/stall-timeout.ts` | [`runtime.md`](runtime.md) | @@ -330,8 +346,10 @@ A source area can be described by more than one doc, because these docs are orga | `src/types/account-auto-pause.ts` | [`account-auto-pause.md`](account-auto-pause.md) | | `src/types/provider.ts` | [`pricing.md`](pricing.md) | | `src/update/` | [`runtime.md`](runtime.md)
[`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | +| `src/update/index.ts` | [`ops/desktop-supervision.md`](ops/desktop-supervision.md) | | `src/update/job-supervised-restart.ts` | [`ops/update-supervised-recovery.md`](ops/update-supervised-recovery.md) | | `src/update/job.ts` | [`ops/update-supervised-recovery.md`](ops/update-supervised-recovery.md) | +| `src/update/restart-ownership.ts` | [`ops/desktop-supervision.md`](ops/desktop-supervision.md) | | `src/update/supervised-recovery.mjs` | [`ops/update-supervised-recovery.md`](ops/update-supervised-recovery.md) | | `src/usage/` | [`runtime.md`](runtime.md)
[`providers/jev-decision.md`](providers/jev-decision.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`companion.md`](companion.md) | | `src/usage/cost.ts` | [`pricing.md`](pricing.md) | diff --git a/structure/desktop-shell.md b/structure/desktop-shell.md index a1f20696868..3fecb250675 100644 --- a/structure/desktop-shell.md +++ b/structure/desktop-shell.md @@ -300,134 +300,15 @@ decision is appended to ## Runtime ownership, from the app's side -`desktop/src-tauri/src/identity.rs` holds this installation's own install id: an opaque value minted -once into the app's config directory and never rewritten, exclusively so two launches racing each -other answer to the same one. It exists because the recorded claim names the owning *installation*, -so the app needs a value of its own to compare against; an id kept only in the shared record would -be whoever wrote it last, and a reinstalled app could not tell its own prior consent from another -installation's. The cost is that a reinstall which keeps the directory keeps its consent and one -that loses it asks again. - -`desktop/src-tauri/src/ownership.rs` mirrors the claim, the three answers a read can give and the -comparison, all of which are defined by -[background-service runtime ownership](runtime.md#background-service-runtime-ownership) and not -here. The shell does not read the record: resolving a claim means reading every state path and -failing closed on an unreadable one, on a corrupt anchor and on paths that disagree, and a second -weaker implementation of a question core already answers is the mistake this tree has made before. -The bundled CLI answers ownership and takeover compatibility through `ocx resolve --json`. -It also answers how the live runtime's version compares to the bundled CLI's -(`versionSkew.relation`; future relation strings read as unknown without discarding the live answer), and the shell acts on the direction instead of reparsing the -warning: `proxy-newer` makes a supported takeover a downgrade, so the run attaches as a -guest with the versions, downgrade risk and verbatim CLI warning rather than asking consent to it. Every other guest path — held -consent, an unreadable owner, a blocked takeover, a declined prompt, a recovery — appends -the CLI's warning to its phase detail, and the consent panel shows it beside the subject. -Unknown ownership never means "nobody owns it". A supported offer shows the endpoint, home -and owner. After consent, the shell resolves again and refuses a changed answer without -invoking stop. It passes the approved token, endpoint and PID to the CLI's opt-in guarded stop. -That command checks the evidence and manager-to-PID binding under its ownership mutation lease -before action; it stops the manager or approved PID, waits within a bounded deadline for PID -exit and endpoint silence, and only then requires definitive manager inactivity. A manager -that remains active or becomes unreadable produces terminal `manager-still-active`, not a stop -receipt. The shell also treats `approval-changed`, unreadable output and child timeout as -terminal before its own silence wait or claim. Only parsed `stopped` or validated exit-79 -`history-incomplete` proceeds to the refused-probe receipt and `ocx service claim`, which -rechecks the approved subject and compatibility. Declining attaches as a guest; a failed claim -does not pretend a stopped runtime was restored. - -Live supervision is a third independent fact, alongside liveness and durable ownership. -On macOS and Linux, the read-only probe verifies that the runtime's direct parent is -`opencodex-desktop` and the child executable is that app's sibling `ocx`; two complete -process snapshots must agree. The target runtime PID, `ocx.pid`, and the PID in -`runtime-port.json` must agree wherever present; mismatches make supervision unknown. -The attestation secret is never included in supervision evidence. On Windows, `ocx.pid` and `runtime-port.json` must both name the runtime PID; its direct parent must be a live `opencodex-desktop.exe`; both images must have drive-qualified paths, with the runtime matching the Desktop image's sibling `ocx.exe` case-insensitively; parent and child must share a session; and the parent must have been created strictly before the child. -Two snapshots must agree, with one bounded PowerShell `Win32_Process` read per snapshot and a six-second budget for the inspection. Inconclusive evidence is `unknown`, retaining `desktopSeen` when a Desktop image was observed. Windows Start at Login is not verified, so live supervision never grants startup protection there. - -Command guards use this live evidence independently of login registration and durable ownership. -Each guarded service command, Node/Bun update run and dashboard restart decision uses one -`createSupervisionLatch()` from `src/service/desktop-supervision.mjs`: `desktop` or -`unknown` with `desktopSeen: true` blocks; only a later positive `none` clears it. A later -plain `unknown` or `unsupported` cannot clear a prior block. Without a prior block, plain -unknown and unsupported preserve the existing durable-owner and liveness decisions. -Service install, repair, start and restart refuse before mutation, including registration staging. -Update gates re-read supervision before stop, package replacement, restoration and recovery; -they refuse even for a separate npm/Bun install. Use the app updater for its bundle, or quit -Desktop before updating the CLI install. Service stop and uninstall remain available. -These decisions follow [runtime ownership](runtime.md#background-service-runtime-ownership). - -The bypass ledger classifies these guards as **early warning**, not enforcement: - -| Guard | Surface / tier | Known limit and residual risk | Pass-through assertion | -| --- | --- | --- | --- | -| Service install/repair/start/restart | CLI before mutation / E3 | Older PATH CLIs or inconclusive evidence without Desktop seen can allow a second supervisor. | With no prior block, none/unsupported preserve ordinary command behavior. | -| Node/Bun update, including pre-stop and recovery | CLI / E3 | Same limits; Desktop can start after the last check inside the stop window, leaving a short race. | None preserves the ordinary update decision. | -| Dashboard restart veto | Server route / E3 | Older runtimes lack this guard. | None preserves the ordinary restart decision. | -| Stop guidance | Notice only / E1 | Desktop may restart the stopped proxy. | `stop --json` neither probes nor emits the notice. | - -When ownership is `none`, `src/service/desktop-startup.ts` can report -`supervisor: { supervisorPid, runtimePid, app }` with `owned: false`. Restart safety -credits this live supervision only when that same app's Start at Login registration is -verified, the proxy is running, and the diagnostic is fresh and viable. This evidence -never creates or changes an ownership claim; unknown ownership and CLI-owned claims do -not enter the unowned diagnostic branch. Unverified login registration produces Desktop -recovery guidance with no recommended shell command. - -Older runtimes cannot report this evidence. When an attested live startup verdict has -no desktop diagnostic but a local probe verifies Desktop supervision of that live PID, -status and doctor use the shared selector's local verdict; status JSON records -`startupSource: "local-supervision-override"`. The service summary uses that selected -verdict too. On Windows, the local reading may be owned or unowned but must bind to the same runtime and supervisor PIDs; macOS and Linux require an unowned local reading. A live verdict that already carries desktop diagnostics remains authoritative. +Durable ownership reads, takeover consent and live-supervision evidence — including +the Windows probe's trusted system-directory acquisition and the shared +`createSupervisionLatch` blocking contract — are owned by +[Desktop supervision](ops/desktop-supervision.md#runtime-ownership-from-the-apps-side). ### Desktop runtime ownership acceptance -The consent surface labels the exact ownership subject it is about to record. A relaunch of the -same desktop installation reuses consent when the recorded `owner` and app-local `installId` -still match; the generation is deliberately not part of that comparison, because the recorded -claim this app holds is its own consent, not a freshness token. -Package update and service repair also preserve that grant and its generation ceiling through -`preservedConsent`; they do not perform a new subject comparison. The write path is stricter than the relaunch -path: a different `owner`, different `installId`, moved `consentGeneration` or unreadable ownership record is not reuse -there — a pending approval is revalidated against the full -subject, so a grant, a release or a re-grant that moved the generation between the prompt and -the write cannot be claimed by the stale approval. On relaunch the same list narrows to the -comparison itself: a different `owner` or `installId` makes the app ask again, and an -unreadable record refuses closed rather than reading as unowned. -Uninstall or an explicit handback releases only the live claim and keeps the generation -ceiling, so a later grant cannot be mistaken for the old one. A runtime still attached to an -old package-owned registration is only attachable as a guest until an ownership-aware CLI -records protocol support; the shell must not treat that attachment as durable takeover consent. - -`desktop/src-tauri/src/first_run.rs` turns Start at Login on once per installation, -before the tray is built so its checkbox reads the resulting state. A menu bar app -that is not running has no menu bar item, so leaving autostart off by default left an -installed app absent after a reboot. The marker in the app config directory is written -before the login item is touched and is never removed, so a user who turns the setting -off keeps it off; writing it afterwards would let a failed enable retry on every launch. -The behaviour is not macOS-only. The autostart plugin implements the macOS and Linux -registrations and reads or disables the current-user Windows Run entry. Shell enables -on Windows write a quoted executable path followed by `--autostart`, preserving the -plugin's registration name and Task Manager explicit-enable semantics. A Windows-specific -launch-origin migration marker revisits older registrations once, only when already -enabled; entries disabled in the tray or Task Manager stay disabled. - -The macOS tray item and the macOS side of this migration judge the login item the way Startup -safety does; see [Desktop Start at Login](desktop-start-at-login.md). - -The WidgetKit extension in `app/` needs three things that Xcode's app-extension target -would supply on its own, and SwiftPM has no such target: `@main` on -`OpenCodexWidgetBundle`, the `-e _NSExtensionMain` linker entry, and -`-application-extension` — the compiler spelling of `APPLICATION_EXTENSION_API_ONLY` — all -in `app/Package.swift`. Any one missing yields a widget that never appears: without -`@main` the linker drops the bundle and the extension registers with nothing to offer, and -without the entry override ExtensionFoundation traps during bootstrap. Nothing observable -distinguishes these from a working widget, because the bundle still builds, signs and -registers. `com.apple.security.app-sandbox` is also mandatory — `pkd` refuses to register -an unsandboxed plug-in at all — which is why the shell writes its snapshot into the -extension's own container rather than a shared App Group, which ad-hoc signing cannot use. - -`desktop/scripts/prepare-sidecar.ts` maps Rust target triples to the standalone -Bun targets and prepares the external binary plus dashboard resources used by -Tauri. Generated files under desktop/src-tauri/binaries/ and -desktop/src-tauri/resources/ remain ignored. +Consent subject comparison, generation ceilings and relaunch reuse are described in +[Desktop supervision](ops/desktop-supervision.md#desktop-runtime-ownership-acceptance). ### Packaged native keyring binding @@ -442,6 +323,11 @@ resolution and never probe beside the shared Bun or Node executable. Compiled in their asset root from the executable's canonical real path, so a symlinked launcher still finds the addon shipped with the real binary. +`desktop/scripts/prepare-sidecar.ts` maps Rust target triples to the standalone +Bun targets and prepares the external binary plus dashboard resources used by +Tauri. Generated files under desktop/src-tauri/binaries/ and +desktop/src-tauri/resources/ remain ignored. + The macOS bundle verifier launches the signed sidecar from a disposable unrelated directory and requires its bounded, load-only keyring probe to expose both native constructors. It does not read or write an OS credential, which would make an ad-hoc CI identity depend on a consent dialog. @@ -554,6 +440,18 @@ The schema version is `1`; the Rust writer refreshes it every five minutes after immediate first write. The WidgetKit appex reads this privacy-safe file and performs no network access. +The WidgetKit extension in `app/` needs three things that Xcode's app-extension target +would supply on its own, and SwiftPM has no such target: `@main` on +`OpenCodexWidgetBundle`, the `-e _NSExtensionMain` linker entry, and +`-application-extension` — the compiler spelling of `APPLICATION_EXTENSION_API_ONLY` — all +in `app/Package.swift`. Any one missing yields a widget that never appears: without +`@main` the linker drops the bundle and the extension registers with nothing to offer, and +without the entry override ExtensionFoundation traps during bootstrap. Nothing observable +distinguishes these from a working widget, because the bundle still builds, signs and +registers. `com.apple.security.app-sandbox` is also mandatory — `pkd` refuses to register +an unsandboxed plug-in at all — which is why the shell writes its snapshot into the +extension's own container rather than a shared App Group, which ad-hoc signing cannot use. + ## The tray icon opens a usage popup `app/Sources/NativeTray/` defines the macOS SwiftUI display model and AppKit panel library. diff --git a/structure/manifest.json b/structure/manifest.json index bee1f5904f1..d106e32a399 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -934,6 +934,33 @@ "src/update/" ] }, + { + "path": "ops/desktop-supervision.md", + "tier": 6, + "title": "Desktop Supervision", + "scope": "Durable Desktop ownership read, takeover-consent handling, live-supervision evidence, the Windows probe's trusted system-directory acquisition, and the supervision latch blocking contract consumed by service and update guards.", + "documents": [ + "bin/ocx.mjs", + "desktop/src-tauri/src/claim.rs", + "desktop/src-tauri/src/first_run.rs", + "desktop/src-tauri/src/identity.rs", + "desktop/src-tauri/src/ownership.rs", + "desktop/src-tauri/src/resolve.rs", + "desktop/src-tauri/src/runtime_stop.rs", + "desktop/src-tauri/src/startup.rs", + "src/cli/desktop-runtime-guidance.ts", + "src/cli/update-restart.ts", + "src/service/cli.ts", + "src/service/desktop-command-guard.ts", + "src/service/desktop-startup.ts", + "src/service/desktop-supervision.d.mts", + "src/service/desktop-supervision.mjs", + "src/service/orchestration.ts", + "src/service/repair.ts", + "src/update/index.ts", + "src/update/restart-ownership.ts" + ] + }, { "path": "ops/service-logging.md", "tier": 6, diff --git a/structure/ops/desktop-supervision.md b/structure/ops/desktop-supervision.md new file mode 100644 index 00000000000..440e32578e1 --- /dev/null +++ b/structure/ops/desktop-supervision.md @@ -0,0 +1,177 @@ +# Desktop supervision + +This document describes durable Desktop ownership, takeover consent and live +supervision. `src/service/desktop-supervision.mjs` (types in +`src/service/desktop-supervision.d.mts`) owns the platform supervision probe, +including Windows system-directory acquisition, and `createSupervisionLatch`; +durable ownership and consent remain with the owners described below. + +## Runtime ownership, from the app's side + +`desktop/src-tauri/src/identity.rs` holds this installation's own install id: an opaque value minted +once into the app's config directory and never rewritten, exclusively so two launches racing each +other answer to the same one. It exists because the recorded claim names the owning *installation*, +so the app needs a value of its own to compare against; an id kept only in the shared record would +be whoever wrote it last, and a reinstalled app could not tell its own prior consent from another +installation's. The cost is that a reinstall which keeps the directory keeps its consent and one +that loses it asks again. + +`desktop/src-tauri/src/ownership.rs` mirrors the claim, the three answers a read can give and the +comparison, all of which are defined by +[background-service runtime ownership](../runtime.md#background-service-runtime-ownership) and not +here. The shell does not read the record: resolving a claim means reading every state path and +failing closed on an unreadable one, on a corrupt anchor and on paths that disagree, and a second +weaker implementation of a question core already answers is the mistake this tree has made before. +The bundled CLI answers ownership and takeover compatibility through `ocx resolve --json` +(`desktop/src-tauri/src/resolve.rs`, inside the launch sequence that +`desktop/src-tauri/src/startup.rs` drives). +It also answers how the live runtime's version compares to the bundled CLI's +(`versionSkew.relation`; future relation strings read as unknown without discarding the live answer), and the shell acts on the direction instead of reparsing the +warning: `proxy-newer` makes a supported takeover a downgrade, so the run attaches as a +guest with the versions, downgrade risk and verbatim CLI warning rather than asking consent to it. Every other guest path — held +consent, an unreadable owner, a blocked takeover, a declined prompt, a recovery — appends +the CLI's warning to its phase detail, and the consent panel shows it beside the subject. +Unknown ownership never means "nobody owns it". A supported offer shows the endpoint, home +and owner. After consent, the shell resolves again and refuses a changed answer without +invoking stop. It passes the approved token, endpoint and PID to the CLI's opt-in guarded +stop (`desktop/src-tauri/src/runtime_stop.rs`). +That command checks the evidence and manager-to-PID binding under its ownership mutation lease +before action; it stops the manager or approved PID, waits within a bounded deadline for PID +exit and endpoint silence, and only then requires definitive manager inactivity. A manager +that remains active or becomes unreadable produces terminal `manager-still-active`, not a stop +receipt. The shell also treats `approval-changed`, unreadable output and child timeout as +terminal before its own silence wait or claim. Only parsed `stopped` or validated exit-79 +`history-incomplete` proceeds to the refused-probe receipt and `ocx service claim` +(`desktop/src-tauri/src/claim.rs`), which rechecks the approved subject and compatibility. +Declining attaches as a guest; a failed claim does not pretend a stopped runtime was restored. + +Live supervision is a third independent fact, alongside liveness and durable ownership. +On macOS and Linux, the read-only probe verifies that the runtime's direct parent is +`opencodex-desktop` and the child executable is that app's sibling `ocx`; two complete +process snapshots must agree. The target runtime PID, `ocx.pid`, and the PID in +`runtime-port.json` must agree wherever present; mismatches make supervision unknown. +The attestation secret is never included in supervision evidence. On Windows, `ocx.pid` and `runtime-port.json` must both name the runtime PID; its direct parent must be a live `opencodex-desktop.exe`; both images must have drive-qualified paths, with the runtime matching the Desktop image's sibling `ocx.exe` case-insensitively; parent and child must share a session; and the parent must have been created strictly before the child. +Two snapshots must agree. Each acquisition is one bounded PowerShell `Win32_Process` +read of the runtime row and its parent's row, emitted as JSON. Both snapshots share a +six-second scheduling budget. Before each acquisition, the caller requires at least +250 ms remaining and assigns the PowerShell subprocess a timeout of at most three +seconds. Synchronous system-directory resolution occurs before that subprocess starts +and has no separate timeout, so these are not hard wall-clock bounds for the complete +inspection. Process evidence is +re-read on every probe; only a successfully resolved system directory is memoized, and +only for the process lifetime, so a failed resolution keeps the retry-on-next-call +recovery contract. + +The directory is resolved before the child spawns and no longer comes from the +environment. `SystemRoot`, `windir` and `PATH` are caller-supplied on this path, and +the fixed `C:\Windows` fallback they used to feed was guesswork, so either could answer +a wrong or tampered `powershell.exe`. `src/service/desktop-supervision.mjs` picks by +runtime instead: on Bun, `bun:ffi` calls kernel32 `GetSystemDirectoryW` directly; on +Node, it derives the directory from `process.report.getReport().sharedObjects` and +accepts it only when `kernel32.dll` and `ntdll.dll` each appear exactly once, +drive-qualified, under one shared parent and below the report's 260-character +module-name buffer — a namesake at the limit may already be truncated and is refused — +and only after the candidate passes F_OK existence checks for both DLLs and +`WindowsPowerShell\v1.0\powershell.exe`. The report object also carries the process's +environment values, so the resolver reads only `.sharedObjects` and never logs, +serializes, stores or forwards the report. `bin/ocx.mjs` imports this module under +Node >=18, so it keeps no TypeScript or Bun-only imports; `bun:ffi` is reached through +`createRequire` inside the Bun branch alone. The Node lane assumes the diagnostic +report enumerates each loaded DLL by path — `getReport` has existed since Node 11.8.0 +(, field layout in +) — and is verified positively only on Node +24.18.0 / Windows x64 and Bun 1.4.4 / Windows x64; the focused tests inject the +resolver and PowerShell output, and Linux CI runs no Windows acquisition, so neither is +native evidence. WoW64, ARM64, a real Desktop-positive supervisor, compiled packaging +and live installs remain unverified. + +The spawned PowerShell receives a minimal environment — `SystemRoot` and `WINDIR` set +to the system directory's parent, `PATH` to the system directory alone and a fixed +`PATHEXT` — with nothing else inherited. Its script disables module autoloading and +imports `Microsoft.PowerShell.Utility` before `CimCmdlets`, because the CimCmdlets +manifest calls `Set-Alias`, which autoload-off cannot supply; the query and the JSON +conversion stay module-qualified. + +Missing pid records, an unavailable or ambiguous report, a duplicated or truncated +namesake, an FFI failure and every other inconclusive read all answer `unknown` — +retaining `desktopSeen` when a Desktop image was observed — never proven `none`, and +callers do not fail closed on plain `unknown`. A Desktop image seen inside otherwise +failed evidence keeps the block; without it, an inconclusive read can still allow an +action a complete probe would have vetoed, which is the early-warning residual the +guard table below records. Windows Start at Login is not verified, so live supervision +never grants startup protection there. + +Command guards use this live evidence independently of login registration and durable ownership. +Each guarded service command, Node/Bun update run and dashboard restart decision uses one +`createSupervisionLatch()` from `src/service/desktop-supervision.mjs`: `desktop` or +`unknown` with `desktopSeen: true` blocks; only a later positive `none` clears it. A later +plain `unknown` or `unsupported` cannot clear a prior block. Without a prior block, plain +unknown and unsupported preserve the existing durable-owner and liveness decisions. +Service install, repair, start and restart refuse before mutation, including registration staging. +Update gates re-read supervision before stop, package replacement, restoration and recovery; +they refuse even for a separate npm/Bun install. Use the app updater for its bundle, or quit +Desktop before updating the CLI install. Service stop and uninstall remain available. +`src/service/desktop-command-guard.ts` carries that refusal for `src/service/cli.ts`, +`src/service/orchestration.ts` and `src/service/repair.ts`; the update lanes take the same +latch in `src/update/index.ts` and `src/update/restart-ownership.ts`, the update-restart +surface in `src/cli/update-restart.ts`, and stop guidance in +`src/cli/desktop-runtime-guidance.ts`. +These decisions follow [runtime ownership](../runtime.md#background-service-runtime-ownership). + +The bypass ledger classifies these guards as **early warning**, not enforcement: + +| Guard | Surface / tier | Known limit and residual risk | Pass-through assertion | +| --- | --- | --- | --- | +| Service install/repair/start/restart | CLI before mutation / E3 | Older PATH CLIs or inconclusive evidence without Desktop seen can allow a second supervisor. | With no prior block, none/unsupported preserve ordinary command behavior. | +| Node/Bun update, including pre-stop and recovery | CLI / E3 | Same limits; Desktop can start after the last check inside the stop window, leaving a short race. | None preserves the ordinary update decision. | +| Dashboard restart veto | Server route / E3 | Older runtimes lack this guard. | None preserves the ordinary restart decision. | +| Stop guidance | Notice only / E1 | Desktop may restart the stopped proxy. | `stop --json` neither probes nor emits the notice. | + +When ownership is `none`, `src/service/desktop-startup.ts` can report +`supervisor: { supervisorPid, runtimePid, app }` with `owned: false`. Restart safety +credits this live supervision only when that same app's Start at Login registration is +verified, the proxy is running, and the diagnostic is fresh and viable. This evidence +never creates or changes an ownership claim; unknown ownership and CLI-owned claims do +not enter the unowned diagnostic branch. Unverified login registration produces Desktop +recovery guidance with no recommended shell command. + +Older runtimes cannot report this evidence. When an attested live startup verdict has +no desktop diagnostic but a local probe verifies Desktop supervision of that live PID, +status and doctor use the shared selector's local verdict; status JSON records +`startupSource: "local-supervision-override"`. The service summary uses that selected +verdict too. On Windows, the local reading may be owned or unowned but must bind to the same runtime and supervisor PIDs; macOS and Linux require an unowned local reading. A live verdict that already carries desktop diagnostics remains authoritative. + +### Desktop runtime ownership acceptance + +The consent surface labels the exact ownership subject it is about to record. A relaunch of the +same desktop installation reuses consent when the recorded `owner` and app-local `installId` +still match; the generation is deliberately not part of that comparison, because the recorded +claim this app holds is its own consent, not a freshness token. +Package update and service repair also preserve that grant and its generation ceiling through +`preservedConsent`; they do not perform a new subject comparison. The write path is stricter than the relaunch +path: a different `owner`, different `installId`, moved `consentGeneration` or unreadable ownership record is not reuse +there — a pending approval is revalidated against the full +subject, so a grant, a release or a re-grant that moved the generation between the prompt and +the write cannot be claimed by the stale approval. On relaunch the same list narrows to the +comparison itself: a different `owner` or `installId` makes the app ask again, and an +unreadable record refuses closed rather than reading as unowned. +Uninstall or an explicit handback releases only the live claim and keeps the generation +ceiling, so a later grant cannot be mistaken for the old one. A runtime still attached to an +old package-owned registration is only attachable as a guest until an ownership-aware CLI +records protocol support; the shell must not treat that attachment as durable takeover consent. + +`desktop/src-tauri/src/first_run.rs` turns Start at Login on once per installation, +before the tray is built so its checkbox reads the resulting state. A menu bar app +that is not running has no menu bar item, so leaving autostart off by default left an +installed app absent after a reboot. The marker in the app config directory is written +before the login item is touched and is never removed, so a user who turns the setting +off keeps it off; writing it afterwards would let a failed enable retry on every launch. +The behaviour is not macOS-only. The autostart plugin implements the macOS and Linux +registrations and reads or disables the current-user Windows Run entry. Shell enables +on Windows write a quoted executable path followed by `--autostart`, preserving the +plugin's registration name and Task Manager explicit-enable semantics. A Windows-specific +launch-origin migration marker revisits older registrations once, only when already +enabled; entries disabled in the tray or Task Manager stay disabled. + +The macOS tray item and the macOS side of this migration judge the login item the way Startup +safety does; see [Desktop Start at Login](../desktop-start-at-login.md). diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 7182595e80a..309caeb0697 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -427,8 +427,7 @@ Plain unknown/unsupported evidence preserves existing decisions unless an earlie `src/update/runtime-ownership.mjs` denies all three update authorities and failed-update recovery when blocked. A restoration refusal after a completed package swap does not undo that update. Service install, repair, start and restart refuse before mutation, including Windows XML staging, -when live Desktop supervision is verified. Windows probes use bounded process snapshots; `ocx stop` shows the Desktop notice. Limits are listed -in [desktop supervision](../desktop-shell.md#runtime-ownership-from-the-apps-side). +when live Desktop supervision is verified; `ocx stop` shows the Desktop notice. Windows probe acquisition and residual limits: [desktop supervision](desktop-supervision.md#runtime-ownership-from-the-apps-side). The lease is released before any service-manager-mediated start (`service repair` in recovery or the post-install refresh): the manager's `ocx start` child cannot join it, and holding it through the repair's health wait keeps that proxy from starting (#5760). The recovery decision @@ -595,6 +594,6 @@ src/update/async-check.ts uses the existing owner-bound registry target with a b The desktop badge snapshot in src/update/desktop-badge.ts is process-local display state keyed by a Tauri session id. A 60-second shell heartbeat renews receipt time; entries expire after 180 seconds and the store retains at most 32 sessions. It is separate from the package version cache and from the updater job/ownership transaction. A proxy restart reports unknown until a bound desktop shell republishes; no update installation can be authorized by this snapshot. -Desktop startup diagnostics use `src/service/desktop-startup.ts` to read the ownership record, the login registration (macOS: launchd; Linux: the `~/.config/autostart/OpenCodex.desktop` entry that auto-launch writes, credited only when `XDG_CONFIG_HOME` is unset or `~/.config`; it must launch an unquoted absolute `opencodex-desktop` with exactly `--autostart`, without conditional keys, and not be hidden or disabled) and exact parent/child executable paths (Linux: `/proc//exe` and the parent pid from `/proc//stat`) without mutating them. A durable desktop claim survives a failed identity or supervision check; its protection requires fresh matching identity, enabled login registration, and live supervision. Only ownership `none` enters the unowned branch: verified supervision adds `supervisor: { supervisorPid, runtimePid, app }` with `owned: false`, and protection requires that same app's verified login registration and a fresh, running, viable diagnostic. Unknown ownership and CLI-owned claims receive no supervision credit. The independent process evidence requires two matching snapshots and correlation of the target, PID-file and runtime-port PIDs, as described in [desktop supervision](../desktop-shell.md#runtime-ownership-from-the-apps-side); it never becomes ownership. An unloaded macOS job receives credit only from two matching plist/target/enabled snapshots; a boolean plist Disabled default combines with explicit launchctl overrides, and malformed or changing evidence refuses credit. On macOS, ownership and PID are re-read before crediting an owned result; on Linux, the complete owned evidence chain is read twice and both reads must agree. The startup-health subprocess uses `selfLaunchArgv` to support both source and compiled entrypoints. +Desktop startup diagnostics use `src/service/desktop-startup.ts` to read the ownership record, the login registration (macOS: launchd; Linux: the `~/.config/autostart/OpenCodex.desktop` entry that auto-launch writes, credited only when `XDG_CONFIG_HOME` is unset or `~/.config`; it must launch an unquoted absolute `opencodex-desktop` with exactly `--autostart`, without conditional keys, and not be hidden or disabled) and exact parent/child executable paths (Linux: `/proc//exe` and the parent pid from `/proc//stat`) without mutating them. A durable desktop claim survives a failed identity or supervision check; its protection requires fresh matching identity, enabled login registration, and live supervision. Only ownership `none` enters the unowned branch: verified supervision adds `supervisor: { supervisorPid, runtimePid, app }` with `owned: false`, and protection requires that same app's verified login registration and a fresh, running, viable diagnostic. Unknown ownership and CLI-owned claims receive no supervision credit. The independent process evidence requires two matching snapshots and correlation of the target, PID-file and runtime-port PIDs, as described in [desktop supervision](desktop-supervision.md#runtime-ownership-from-the-apps-side); it never becomes ownership. An unloaded macOS job receives credit only from two matching plist/target/enabled snapshots; a boolean plist Disabled default combines with explicit launchctl overrides, and malformed or changing evidence refuses credit. On macOS, ownership and PID are re-read before crediting an owned result; on Linux, the complete owned evidence chain is read twice and both reads must agree. The startup-health subprocess uses `selfLaunchArgv` to support both source and compiled entrypoints. On Linux, a dashboard update worker started from the systemd user service is launched through an executable regular file at a trusted absolute path — `/usr/bin/systemd-run`, `/bin/systemd-run`, `/usr/local/bin/systemd-run` (local installs), or `/run/current-system/sw/bin/systemd-run` (the NixOS layout) — with `--user --scope --quiet --collect` (`src/update/worker-launch.ts`), so it leaves the service cgroup before the updater stops `opencodex-proxy.service`; the default `KillMode=control-group` otherwise kills it with the proxy (#5750). The inherited `PATH` is never searched, and each candidate's resolved target — plus every ancestor directory able to substitute it — must be root-owned and not group/world-writable: a trusted-path symlink into a user-replaceable directory is skipped, as is a group-writable `/usr/local/bin`, rather than exec'd under the service account. Candidates are tried in order and a path whose no-op scope probe fails falls through to the next trusted path; the probe applies only when `INVOCATION_ID` is set, and every other case keeps the plain detached spawn. The management route resolves the launcher with `resolveSystemdRunAsync` before spawning, so first-request probing overlaps other work instead of blocking the event loop for up to twenty seconds. `--scope` moves `systemd-run` itself into the scope and then execs the worker, so the recorded PID is the worker's (`tests/update/update-worker-launch.test.ts`). diff --git a/tests/clients/desktop-install-identity.test.ts b/tests/clients/desktop-install-identity.test.ts index a53110ea3cf..fb9889d199c 100644 --- a/tests/clients/desktop-install-identity.test.ts +++ b/tests/clients/desktop-install-identity.test.ts @@ -24,6 +24,7 @@ const STARTUP = repoPath(`${SHELL}/startup.rs`); const STATE = repoPath("src/service/state.ts"); const COMPATIBILITY = repoPath("src/service/ownership-compatibility.ts"); const DESKTOP_SHELL_DOC = repoPath("structure/desktop-shell.md"); +const DESKTOP_SUPERVISION_DOC = repoPath("structure/ops/desktop-supervision.md"); function code(path: string): string { return readFileSync(path, "utf8").replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, ""); @@ -35,6 +36,7 @@ describe("desktop install identity", () => { const state = code(STATE); const compatibility = code(COMPATIBILITY); const desktopShellDoc = readFileSync(DESKTOP_SHELL_DOC, "utf8"); + const desktopSupervisionDoc = readFileSync(DESKTOP_SUPERVISION_DOC, "utf8"); test("the installation's id is minted once and never rewritten", () => { // Exclusive, because two launches racing to mint would answer to two ids, and the second one @@ -136,13 +138,19 @@ describe("desktop install identity", () => { }); test("the desktop acceptance text names the consent reuse boundaries", () => { - expect(desktopShellDoc).toContain("Desktop runtime ownership acceptance"); - expect(desktopShellDoc).toMatch(/same desktop installation reuses consent/i); - expect(desktopShellDoc).toMatch(/Package update and service repair also preserve that grant/i); - expect(desktopShellDoc).toMatch(/different `owner`, different `installId`, moved\s+`consentGeneration`/i); - expect(desktopShellDoc).toMatch(/unreadable ownership record is not reuse/i); - expect(desktopShellDoc).toMatch(/Uninstall or an explicit handback releases only the live claim/i); - expect(desktopShellDoc).toMatch(/old package-owned registration is only attachable as a guest/i); + // The acceptance contract moved to ops/desktop-supervision.md; the shell doc must keep + // both old anchors and their forwarding links so inbound references still resolve. + expect(desktopShellDoc).toContain("## Runtime ownership, from the app's side"); + expect(desktopShellDoc).toContain("### Desktop runtime ownership acceptance"); + expect(desktopShellDoc).toContain("ops/desktop-supervision.md#runtime-ownership-from-the-apps-side"); + expect(desktopShellDoc).toContain("ops/desktop-supervision.md#desktop-runtime-ownership-acceptance"); + expect(desktopSupervisionDoc).toContain("### Desktop runtime ownership acceptance"); + expect(desktopSupervisionDoc).toMatch(/same desktop installation reuses consent/i); + expect(desktopSupervisionDoc).toMatch(/Package update and service repair also preserve that grant/i); + expect(desktopSupervisionDoc).toMatch(/different `owner`, different `installId`, moved\s+`consentGeneration`/i); + expect(desktopSupervisionDoc).toMatch(/unreadable ownership record is not reuse/i); + expect(desktopSupervisionDoc).toMatch(/Uninstall or an explicit handback releases only the live claim/i); + expect(desktopSupervisionDoc).toMatch(/old package-owned registration is only attachable as a guest/i); }); test("service refresh preserves consent and handback releases only the live claim", () => { diff --git a/tests/config/settings-stream-mode.test.ts b/tests/config/settings-stream-mode.test.ts index f6a13112add..c080b10a866 100644 --- a/tests/config/settings-stream-mode.test.ts +++ b/tests/config/settings-stream-mode.test.ts @@ -231,6 +231,7 @@ describe("GET /api/settings", () => { test("reports redacted codexRuntime diagnostics and clamp correlation", async () => { const { chmodSync } = await import("node:fs"); const { + peekCodexRuntimeProcessCache, persistEffortClamp, resetCodexRuntimeResolveCacheForTests, resolveCodexRuntimeAsync, @@ -261,7 +262,12 @@ describe("GET /api/settings", () => { process.env.PATH = ""; // Settings serve the runtime stale-while-revalidate; land the probe first so this // asserts the validated projection rather than the cold deferred answer. - await resolveCodexRuntimeAsync(); + const resolveStarted = performance.now(); + const resolved = await resolveCodexRuntimeAsync(); + const resolveMs = Math.round(performance.now() - resolveStarted); + // Read-only peek (never a probe) so the version assertion below can report + // whether the memo the GET is expected to serve was actually published. + const cachePeek = peekCodexRuntimeProcessCache(); const body = await (await getSettings(baseConfig()))!.json() as { codexRuntime?: { path?: string; @@ -276,7 +282,38 @@ describe("GET /api/settings", () => { // OPENCODEX_HOME lives under the OS user profile; username must stay redacted on all OS. expect(body.codexRuntime?.path?.toLowerCase()).not.toMatch(/[/\\]users[/\\][^/\\[\]]+[/\\]/i); expect(body.codexRuntime?.path?.toLowerCase()).not.toContain("alice"); - expect(body.codexRuntime?.version).toBe("0.133.0"); + // Failure-only context for the macOS 0.133.0 -> null control result. The + // message renders only when this assertion fails and stays path-free: + // fixed reason buckets instead of raw output, and no commands or env. + const knownReasons = new Set([ + "path does not exist", + "program not found (ENOENT)", + "not a spawnable Codex launcher on this platform", + "Codex app bundle identity mismatch or unavailable", + "unrecognized --version output", + "probe sandbox unavailable", + ]); + const probeReasonSummary = (reason: string): string => { + if (reason.startsWith("failed --version")) { + return reason.includes("deadline") ? "failed --version (deadline)" : "failed --version"; + } + return knownReasons.has(reason) ? reason : "other"; + }; + const diagnostics = JSON.stringify({ + resolveMs, + resolved: { + source: resolved.runtime.source, + version: resolved.runtime.version, + failures: resolved.failures.map(item => ({ source: item.source, reason: probeReasonSummary(item.reason) })), + }, + cache: { kind: cachePeek.kind, epoch: cachePeek.epoch }, + projection: { + source: body.codexRuntime?.source ?? null, + version: body.codexRuntime?.version ?? null, + warningPresent: typeof body.codexRuntime?.warning === "string", + }, + }); + expect(body.codexRuntime?.version, `codexRuntime diagnostics: ${diagnostics}`).toBe("0.133.0"); expect(body.codexRuntime?.source).toBe("environment"); expect(body.codexRuntime?.catalogClamp).toEqual({ active: true, diff --git a/tests/service/service-desktop-supervision-windows.test.ts b/tests/service/service-desktop-supervision-windows.test.ts index 57df12156bb..4c5906a514c 100644 --- a/tests/service/service-desktop-supervision-windows.test.ts +++ b/tests/service/service-desktop-supervision-windows.test.ts @@ -8,6 +8,7 @@ import { assertNoDesktopSupervision, desktopServiceCommandRefusal } from "../../ import { desktopStartupOwnership, diagnoseDesktopStartup, diagnoseWindowsDesktopStartup } from "../../src/service/desktop-startup"; import { createSupervisionLatch, inspectDesktopSupervision, windowsPowerShellPath, windowsProcessPair, + windowsSystemDirectoryFromSharedObjects, type DesktopSupervisionDeps, type WindowsProcessRow, } from "../../src/service/desktop-supervision.mjs"; import type { ServiceOwnershipResolution } from "../../src/service/state"; @@ -166,30 +167,40 @@ test("one inspection shares a 6 s budget: slow successful reads pass, an exhaust expect(exhausted.state.reads).toBe(1); }); -test("the default reader runs one fixed PowerShell under the system root with a numeric filter", () => { - const calls: { command: string; args: string[]; timeout?: number }[] = []; +test("the default reader uses a trusted system directory, disables autoloading and sanitizes its environment", () => { + const calls: { command: string; args: string[]; timeout?: number; env?: NodeJS.ProcessEnv }[] = []; const output = JSON.stringify({ child: { pid: "100", parent: "200", exe: PROXY, session: "1", created: "638950000000000002" }, parent: { pid: "200", parent: "4", exe: APP, session: "1", created: "638950000000000001" }, }); - const run = (command: string, args: string[], timeout?: number) => { calls.push({ command, args, timeout }); return output; }; - expect(windowsProcessPair(100, run, { env: { SystemRoot: "D:\\Win" }, timeoutMs: 1_234 })).toEqual({ + const run = (command: string, args: string[], timeout?: number, env?: NodeJS.ProcessEnv) => { + calls.push({ command, args, timeout, env }); return output; + }; + expect(windowsProcessPair(100, run, { resolveSystemDirectory: () => "D:\\Win\\System32", timeoutMs: 1_234 })).toEqual({ child: { pid: 100, parent: 200, exe: PROXY, session: 1, created: "638950000000000002" }, parent: { pid: 200, parent: 4, exe: APP, session: 1, created: "638950000000000001" }, }); expect(calls[0]!.command).toBe("D:\\Win\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"); expect(calls[0]!.args.slice(0, 4)).toEqual(["-NoProfile", "-NoLogo", "-NonInteractive", "-Command"]); expect(calls[0]!.args[4]).toContain("-Filter 'ProcessId=100'"); + expect(calls[0]!.args[4]).toContain("$PSModuleAutoLoadingPreference='None'"); + // Utility must load before CimCmdlets: CimCmdlets' own import calls Set-Alias and autoload is off. + expect(calls[0]!.args[4]!.indexOf("Microsoft.PowerShell.Utility.psd1")) + .toBeLessThan(calls[0]!.args[4]!.indexOf("CimCmdlets.psd1")); + expect(calls[0]!.args[4]).toContain("CimCmdlets\\Get-CimInstance"); + expect(calls[0]!.env).toEqual({ + SystemRoot: "D:\\Win", WINDIR: "D:\\Win", PATH: "D:\\Win\\System32", PATHEXT: ".COM;.EXE;.BAT;.CMD", + }); expect(calls[0]!.timeout).toBe(1_234); - for (const env of [{}, { SystemRoot: "Windows" }, { SystemRoot: "\\\\server\\share" }]) { - expect(windowsPowerShellPath(env)).toBe("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"); - } + expect(windowsPowerShellPath(() => "C:\\Windows\\System32")) + .toBe("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"); for (const pid of [0, -1, 1.5, Number.NaN]) expect(() => windowsProcessPair(pid, run)).toThrow("invalid pid"); expect(calls).toHaveLength(1); }); test("through the default reader, a Desktop image with malformed evidence keeps desktopSeen; unreadable output does not", () => { - const base = { platform: "win32" as const, readPid: () => 100, readRuntimePortPid: () => 100 }; + const base = { platform: "win32" as const, readPid: () => 100, readRuntimePortPid: () => 100, + resolveWindowsSystemDirectory: () => "C:\\Windows\\System32" }; const malformed = JSON.stringify({ child: { pid: "100", parent: "200", exe: PROXY, session: "1", created: "638950000000000002" }, parent: { pid: "200", parent: "4", exe: APP, session: "1", created: "not-ticks" }, @@ -290,3 +301,81 @@ test("the ownership-only Windows projection runs no process probe", () => { expect(f.state.reads).toBe(0); }); +test("the Node sharedObjects resolver rejects duplicates, malformed, truncated and disagreeing enumerations", () => { + const dir = "C:\\Windows\\System32"; + const good = ["C:\\Windows\\System32\\KERNEL32.DLL", "c:\\windows\\system32\\ntdll.dll", "C:\\ocx\\node.exe"]; + // A consistent same-parent enumeration (mixed case, mixed separators) resolves. + expect(windowsSystemDirectoryFromSharedObjects(good).toLowerCase()).toBe(dir.toLowerCase()); + expect(windowsSystemDirectoryFromSharedObjects(["C:/Windows/System32/kernel32.dll", "C:\\Windows\\System32\\ntdll.dll"]).toLowerCase()) + .toBe(dir.toLowerCase()); + // Environment tampering cannot steer this resolver — it never reads process.env. + const saved = process.env.SystemRoot; + try { + process.env.SystemRoot = "D:\\Evil"; + expect(windowsSystemDirectoryFromSharedObjects(good).toLowerCase()).toBe(dir.toLowerCase()); + } finally { + if (saved === undefined) delete process.env.SystemRoot; else process.env.SystemRoot = saved; + } + // An unavailable report is the bounded unknown residual, not a success. + for (const loaded of [undefined, null, 42, "C:\\Windows\\System32"]) { + expect(() => windowsSystemDirectoryFromSharedObjects(loaded)).toThrow("trusted Windows resolver unavailable"); + } + // Same-directory duplicate namesakes — contradictions are rejected. + expect(() => windowsSystemDirectoryFromSharedObjects([...good, "C:\\Windows\\System32\\kernel32.dll"])) + .toThrow("trusted Windows resolver unavailable"); + expect(() => windowsSystemDirectoryFromSharedObjects([...good, "D:\\x\\kernel32.dll"])) + .toThrow("trusted Windows resolver unavailable"); + // Malformed namesakes (bare name, UNC, rooted, drive-relative) must not hide behind the format filter. + for (const namesake of ["kernel32.dll", "\\\\evil\\share\\kernel32.dll", "\\System32\\kernel32.dll", "C:kernel32.dll"]) { + expect(() => windowsSystemDirectoryFromSharedObjects([...good, namesake])).toThrow("trusted Windows resolver unavailable"); + } + // A namesake at or over the enumeration buffer limit may be truncated — reject rather than trust. + expect(() => windowsSystemDirectoryFromSharedObjects([...good, "D:\\" + "x".repeat(300) + "\\kernel32.dll"])) + .toThrow("trusted Windows resolver unavailable"); + // kernel32 and ntdll must share one parent directory. + expect(() => windowsSystemDirectoryFromSharedObjects(["C:\\Windows\\System32\\kernel32.dll", "D:\\Other\\ntdll.dll"])) + .toThrow("trusted Windows resolver unavailable"); + // An incomplete enumeration (missing ntdll) cannot prove a directory. + expect(() => windowsSystemDirectoryFromSharedObjects(["C:\\Windows\\System32\\kernel32.dll"])) + .toThrow("trusted Windows resolver unavailable"); +}); + +test("the Node sharedObjects resolver drives the real probe path and fails closed when it rejects", () => { + const output = JSON.stringify({ + child: { pid: "100", parent: "200", exe: PROXY, session: "1", created: "638950000000000002" }, + parent: { pid: "200", parent: "4", exe: APP, session: "1", created: "638950000000000001" }, + }); + const calls: { command: string; args: string[]; timeout?: number; env?: NodeJS.ProcessEnv }[] = []; + const run = (command: string, args: string[], timeout?: number, env?: NodeJS.ProcessEnv) => { + calls.push({ command, args, timeout, env }); return output; + }; + // Positive: a consistent Node enumeration supplies the system directory for the real + // windowsProcessPair path — PowerShell runs at the derived location, not from env or PATH. + const loaded = ["C:\\Windows\\System32\\kernel32.dll", "c:\\windows\\system32\\ntdll.dll", "C:\\Program Files\\nodejs\\node.exe"]; + const deps = { + platform: "win32" as const, readPid: () => 100, readRuntimePortPid: () => 100, + resolveWindowsSystemDirectory: () => windowsSystemDirectoryFromSharedObjects(loaded), run, + } satisfies DesktopSupervisionDeps; + expect(inspectDesktopSupervision(deps)).toEqual({ kind: "desktop", runtimePid: 100, supervisorPid: 200, app: APP, proxy: PROXY }); + expect(calls.length).toBe(2); // two agreeing snapshots, one bounded probe each + for (const call of calls) { + expect(call.command).toBe("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"); + expect(call.env?.PATH).toBe("C:\\Windows\\System32"); + } + // Negative: a rejecting enumeration never reaches PowerShell — the result is the + // bounded probe-failed unknown, never proven absence, and plain unknown stays nonblocking. + const refused = ["kernel32.dll"]; // malformed namesake — no drive-qualified path + const spy: typeof run = (command, args, timeout, env) => { calls.push({ command, args, timeout, env }); return output; }; + const before = calls.length; + const evidence = inspectDesktopSupervision({ ...deps, + resolveWindowsSystemDirectory: () => windowsSystemDirectoryFromSharedObjects(refused), run: spy }); + expect(evidence).toEqual({ kind: "unknown", reason: "probe-failed", desktopSeen: false }); + expect(calls).toHaveLength(before); + expect(createSupervisionLatch().observe(evidence)).toBe(false); + expect(planUpdateRuntimeHandling({ ownership: null, serviceInstalled: false, supervision: evidence }).mayReplacePackage).toBe(true); + // A throwing resolver fails identically at the pair level: execute is never invoked. + expect(() => windowsProcessPair(100, spy, { resolveSystemDirectory: () => { throw new Error("trusted Windows resolver unavailable"); } })) + .toThrow("trusted Windows resolver unavailable"); + expect(calls).toHaveLength(before); +}); + From 3b4e04ae109a58f662270eb19a94a1861a7a3c36 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Mon, 26 Oct 2026 10:25:50 +0000 Subject: [PATCH 079/101] fix(test): bind and serialize queue lock ownership (#7508) * fix(test): bind queue locks to process start identity * fix(test): serialize lock generation transitions * fix(test): promote Bun 1.4.4 with independent runner role verification * docs: align Docker Bun comments with unified role defaults * test: use a draining TLS origin for early upload replies * test: confine draining origin to buffered HTTP/1 upload * test(lock): stage-time gate calls in busy-owner release diagnostic Instrument the real release() path with temporary bun:sqlite mocks so a bound violation reports open/pragma/BEGIN/close timings. Correctness check (generation preservation) now runs before the timing bounds. Budgets and SQLite behavior unchanged; diagnostic prints only when the 13s bound is exceeded. * fix(test): bound release gate entry by elapsed deadline sqlite3_busy_timeout bounds cumulative scheduled sleep, not elapsed time; hosted darwin runners overshoot it past the release bound. Retry BEGIN IMMEDIATE in 250ms slices under a monotonic deadline so the wait is bounded in elapsed time. The deadline covers only gate entry; a granted action still runs to completion exactly once. busyMs=0 keeps its single immediate attempt. * test(lock): control liveness poll phases independently of wall time Keep the real generation gate and filesystem, original 20ms/5ms queue settings and stale assertions. Advance the test clock only at wait/fresh-owner evidence boundaries and retain a monotonic escape under the existing internal deadline. Restore the spy and remove the fixture after acquisition settles. --------- Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> --- scripts/test-layout/layout.json | 2 +- scripts/test-run-lock-gate.ts | 127 +++++ scripts/test-run-lock.ts | 510 +++++++++++++++--- structure/INDEX.md | 4 +- structure/manifest.json | 4 +- structure/ops/test-sandbox-cleanup.md | 29 + .../test-run-lock-generation.test.ts | 384 +++++++++++++ tests/ci-workflows/test-runner.test.ts | 225 ++++++++ tests/fixtures/test-layout-expected.json | 2 +- .../nested-test-run-lock-controller.ts | 4 +- .../test-run-lock-generation-worker.ts | 42 ++ 11 files changed, 1263 insertions(+), 70 deletions(-) create mode 100644 scripts/test-run-lock-gate.ts create mode 100644 tests/ci-workflows/test-run-lock-generation.test.ts create mode 100644 tests/helpers/test-run-lock-generation-worker.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index bc218fcb7b0..4a54711c181 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1821,7 +1821,7 @@ "terminal-guard-server.test.ts": "server", "terminal-guard.test.ts": "server", "test-home-guard.test.ts": "ci-workflows", "test-home-guard-unpreloaded.test.ts": "ci-workflows", - "test-runner.test.ts": "ci-workflows", "test-runner-bun.test.ts": "ci-workflows", "test-runner-spawnsync-finalizer.test.ts": "ci-workflows", "test-sandbox-cleanup.test.ts": "ci-workflows", "test-temp-quarantine.test.ts": "ci-workflows", + "test-run-lock-generation.test.ts": "ci-workflows", "test-runner.test.ts": "ci-workflows", "test-runner-bun.test.ts": "ci-workflows", "test-runner-spawnsync-finalizer.test.ts": "ci-workflows", "test-sandbox-cleanup.test.ts": "ci-workflows", "test-temp-quarantine.test.ts": "ci-workflows", "thought-signature-credential-scope.test.ts": "responses", "token-estimate.test.ts": "lib", "token-guardian.test.ts": "codex-integration", diff --git a/scripts/test-run-lock-gate.ts b/scripts/test-run-lock-gate.ts new file mode 100644 index 00000000000..6a51dc543af --- /dev/null +++ b/scripts/test-run-lock-gate.ts @@ -0,0 +1,127 @@ +import { chmodSync, lstatSync, realpathSync } from "node:fs"; +import { basename, dirname, isAbsolute, join } from "node:path"; +import { constants, Database } from "bun:sqlite"; +import { samePathIdentity } from "../src/codex/user-identity"; +import { hardenSecretDir, hardenSecretPath } from "../src/lib/windows-secret-acl"; + +export const TEST_RUN_LOCK_RELEASE_WAIT_MS = 10_000; + +// sqlite3_busy_timeout bounds cumulative *scheduled* sleep, not elapsed time: +// each native wait may overshoot. An overshooting BEGIN IMMEDIATE wait was +// observed exceeding this budget in hosted CI. Retrying in slices under a +// monotonic deadline bounds the elapsed wait regardless of per-sleep +// overshoot; the bound is a deadline, not a realtime guarantee. +const BUSY_WAIT_SLICE_MS = 250; + +function code(error: unknown): string | undefined { + return (error as NodeJS.ErrnoException)?.code; +} + +function refuse(): never { + throw new Error("Cannot open the Bun test lock generation gate: unsafe path, permissions or file identity."); +} + +function inspectFile(path: string): ReturnType | undefined { + try { + const entry = lstatSync(path); + if (!entry.isFile() || entry.isSymbolicLink() || entry.nlink !== 1) refuse(); + if (process.platform !== "win32" && entry.uid !== process.getuid?.()) refuse(); + if (!samePathIdentity(path, realpathSync.native(path), process.platform)) refuse(); + return entry; + } catch (error) { + if (code(error) === "ENOENT") return undefined; + throw error; + } +} + +function openGate(lockPath: string): { db: Database; identity: { dev: number | bigint; ino: number | bigint } } { + if (!isAbsolute(lockPath)) refuse(); + const requestedParent = dirname(lockPath); + // Windows' OS temp path can use an 8.3 spelling. Canonicalize that alias, + // without silently accepting a junction/symlink anywhere in its ancestry. + for (let ancestor = requestedParent;; ancestor = dirname(ancestor)) { + const entry = lstatSync(ancestor); + if (entry.isSymbolicLink()) { + // macOS exposes its OS-owned temp hierarchy through these system aliases. + const systemAlias = process.platform === "darwin" && (ancestor === "/var" || ancestor === "/tmp") + && entry.uid === 0 && realpathSync.native(ancestor) === `/private${ancestor}` + && lstatSync(`/private${ancestor}`).uid === 0; + if (!systemAlias) refuse(); + } + if (dirname(ancestor) === ancestor) break; + } + const parent = realpathSync.native(requestedParent); + const directory = lstatSync(parent); + if (!directory.isDirectory() || directory.isSymbolicLink() + || !samePathIdentity(parent, realpathSync.native(parent), process.platform)) refuse(); + if (process.platform === "win32") hardenSecretDir(parent, { required: true }); + else if (directory.uid !== process.getuid?.() || (directory.mode & 0o777) !== 0o700) refuse(); + const path = join(parent, `${basename(lockPath)}.gate.sqlite`); + const before = inspectFile(path); + // Never fs.open/close this inode: POSIX close can cancel another SQLite + // connection's locks in this process. The private parent protects creation. + const db = Database.open(path, + constants.SQLITE_OPEN_READWRITE | constants.SQLITE_OPEN_CREATE | constants.SQLITE_OPEN_NOFOLLOW); + try { + const opened = inspectFile(path); + if (!opened || (before && (before.dev !== opened.dev || before.ino !== opened.ino))) refuse(); + if (process.platform === "win32") hardenSecretPath(path, { required: true }); + else chmodSync(path, 0o600); + const secured = inspectFile(path); + const parentAfter = lstatSync(parent); + if (!secured || secured.dev !== opened.dev || secured.ino !== opened.ino + || directory.dev !== parentAfter.dev || directory.ino !== parentAfter.ino) refuse(); + return { db, identity: secured }; + } catch (error) { + db.close(true); + throw error; + } +} + +/** Permanent sibling: never unlink/replace it or PID/mtime-reclaim it. + * Old executables do not participate: metadata compatibility does not stop an + * old in-flight actor's already-decided pathname operations. + */ +export function openTestRunLockGate(lockPath: string) { + const { db, identity } = openGate(lockPath); + return { + run(action: () => T, busyMs = 0): { value: T } | undefined { + const current = inspectFile(db.filename); + if (!current || current.dev !== identity.dev || current.ino !== identity.ino) refuse(); + let entered = false; + try { + // The deadline bounds only the wait for gate entry. Once BEGIN + // IMMEDIATE is granted the action runs to completion, exactly once, + // outside the budget — same semantics as a single long wait. + // A non-finite budget is not a meaningful wait and would never satisfy + // the deadline comparison: normalize to the documented busyMs=0 + // single immediate attempt rather than looping. + const budget = Number.isFinite(busyMs) ? Math.max(0, Math.floor(busyMs)) : 0; + const deadline = performance.now() + budget; + for (;;) { + const remaining = Math.max(0, Math.floor(deadline - performance.now())); + db.exec(`PRAGMA busy_timeout = ${Math.min(remaining, BUSY_WAIT_SLICE_MS)}`); + try { + db.exec("BEGIN IMMEDIATE"); + entered = true; + break; + } catch (error) { + if (code(error) !== "SQLITE_BUSY") throw error; + if (budget <= 0 || performance.now() >= deadline) return undefined; + } + } + // A bounded busy wait can outlive external pathname interference. + const locked = inspectFile(db.filename); + if (!locked || locked.dev !== identity.dev || locked.ino !== identity.ino) refuse(); + return { value: action() }; + } finally { if (entered) db.exec("ROLLBACK"); } + }, + close() { db.close(true); }, + }; +} + +export function withTestRunLockGate(lockPath: string, action: () => T, busyMs = 0): { value: T } | undefined { + const gate = openTestRunLockGate(lockPath); + try { return gate.run(action, busyMs); } + finally { gate.close(); } +} diff --git a/scripts/test-run-lock.ts b/scripts/test-run-lock.ts index edabe8ccfda..6378a211125 100644 --- a/scripts/test-run-lock.ts +++ b/scripts/test-run-lock.ts @@ -1,3 +1,4 @@ +import { execFileSync } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; import { accessSync, @@ -7,18 +8,22 @@ import { readFileSync, readdirSync, renameSync, + rmdirSync, rmSync, statSync, writeFileSync, } from "node:fs"; import { hostname, tmpdir } from "node:os"; import { join, posix, win32 } from "node:path"; +import { dlopen, FFIType } from "bun:ffi"; import type { UserIdentity } from "../src/codex/convergence-types"; import { resolveEffectiveUserIdentity, resolveEffectiveUserRuntimeRoot, } from "../src/codex/user-identity"; +import { resolveTrustedWindowsPowerShellExe } from "../src/lib/windows-elevation"; import { hardenSecretDir } from "../src/lib/windows-secret-acl"; +import { openTestRunLockGate, TEST_RUN_LOCK_RELEASE_WAIT_MS, withTestRunLockGate } from "./test-run-lock-gate"; export const TEST_RUN_ID_ENV = "OCX_TEST_RUN_ID"; export const TEST_RUN_LOCK_PATH_ENV = "OCX_TEST_RUN_LOCK_PATH"; @@ -66,6 +71,7 @@ export interface TestRunLockOwner { token: string; pid: number; acquiredAt: string; + processStart?: string; } export interface TestRunLock { @@ -85,8 +91,15 @@ export interface AcquireTestRunLockOptions { env?: NodeJS.ProcessEnv; onWait?: (owner: TestRunLockOwner | null) => void; onAcquiredAfterWait?: (elapsedMs: number) => void; + /** Unreadable process identity preserves the conservative live-PID veto. */ + processStartBatch?: TestRunProcessStartReader; } +/** Platform-tagged process-start identities; null means unreadable. */ +export type TestRunProcessStartReader = ( + pids: readonly number[], +) => ReadonlyMap; + export interface ResolveInheritedTestRunLockOptions { wrappedRunId?: string; env?: NodeJS.ProcessEnv; @@ -375,8 +388,11 @@ function ownerPath(lockPath: string): string { return join(lockPath, OWNER_FILE); } -function memberPath(lockPath: string, owner: TestRunLockOwner, pid: number): string { - return join(lockPath, MEMBERS_DIR, `${pid}-${owner.token}`); +function memberPath(lockPath: string, owner: TestRunLockOwner, pid: number, start?: string): string { + // Old readers parseInt the prefix and match the token suffix. A separate path + // prevents an old writer's append-empty registration retaining a stale identity. + const prefix = start ? `${pid}.v2.${createHash("sha256").update(start).digest("hex")}` : String(pid); + return join(lockPath, MEMBERS_DIR, `${prefix}-${owner.token}`); } function readOwner(lockPath: string): TestRunLockOwner | null { @@ -386,60 +402,376 @@ function readOwner(lockPath: string): TestRunLockOwner | null { || !Number.isInteger(parsed.pid) || (parsed.pid ?? 0) <= 0 || typeof parsed.acquiredAt !== "string") { return null; } - return parsed as TestRunLockOwner; - } catch { - return null; + // A malformed optional identity must not discard an otherwise live owner. + const { processStart, ...owner } = parsed; + return { ...owner, ...(validProcessStart(processStart) ? { processStart } : {}) } as TestRunLockOwner; + } catch (error) { + if (error instanceof SyntaxError || errorCode(error) === "ENOENT") return null; + throw error; } } -function processIsAlive(pid: number): boolean { +function processIsAlive(pid: number): boolean | null { try { process.kill(pid, 0); return true; } catch (error) { - return (error as NodeJS.ErrnoException).code === "EPERM"; + return (error as NodeJS.ErrnoException).code === "ESRCH" ? false : null; } } -function liveMemberExists(lockPath: string, owner: TestRunLockOwner): boolean { +function validProcessStart(value: unknown): value is string { + return typeof value === "string" && ( + /^linux:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}:(0|[1-9]\d*)$/.test(value) + || (/^win32:[1-9]\d{0,15}$/.test(value) && Number.isSafeInteger(Number(value.slice(6)))) + || (/^darwin:\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(value) + && Number.isFinite(Date.parse(value.slice(7))) + && new Date(value.slice(7)).toISOString().replace(".000Z", "Z") === value.slice(7)) + ); +} + +/** No wall-clock conversion: btime shifts when the system clock is corrected. */ +function linuxProcessStart(pid: number): string | null { try { - return readdirSync(join(lockPath, MEMBERS_DIR)).some(file => { - const suffix = `-${owner.token}`; - if (!file.endsWith(suffix)) return false; - const pid = Number.parseInt(file.slice(0, -suffix.length), 10); - return Number.isInteger(pid) && pid > 0 && processIsAlive(pid); + return parseLinuxTestProcessStart( + readFileSync(`/proc/${pid}/stat`, "utf8"), + readFileSync("/proc/sys/kernel/random/boot_id", "utf8"), + ); + } catch { + return null; + } +} + +export function parseLinuxTestProcessStart(stat: string, bootId: string): string | null { + const close = stat.lastIndexOf(")"); + if (close < 0) return null; + const ticks = stat.slice(close + 2).trim().split(/\s+/)[19]; + if (!ticks || !/^\d{1,20}$/.test(ticks)) return null; + const identity = `linux:${bootId.trim().toLowerCase()}:${BigInt(ticks)}`; + return validProcessStart(identity) ? identity : null; +} + +/** Strict English ps tokens in UTC; never parse a locale-dependent local date. */ +export function parseDarwinTestProcessStart(value: string): string | null { + const match = /^(Sun|Mon|Tue|Wed|Thu|Fri|Sat) +(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) +(\d{1,2}) +(\d{2}):(\d{2}):(\d{2}) +(\d{4})$/.exec(value.trim()); + if (!match) return null; + const month = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"].indexOf(match[2]!); + const parts = [Number(match[7]), month, Number(match[3]), Number(match[4]), Number(match[5]), Number(match[6])] as const; + const date = new Date(Date.UTC(...parts)); + if (date.getUTCFullYear() !== parts[0] || date.getUTCMonth() !== month + || date.getUTCDate() !== parts[2] || date.getUTCHours() !== parts[3] + || date.getUTCMinutes() !== parts[4] || date.getUTCSeconds() !== parts[5] + || ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][date.getUTCDay()] !== match[1]) return null; + return `darwin:${date.toISOString().replace(".000Z", "Z")}`; +} + +function darwinProcessStartBatch(pids: readonly number[]): Map { + const out = new Map(pids.map(pid => [pid, null])); + if (pids.length === 0) return out; + try { + const stdout = execFileSync("/bin/ps", ["-o", "pid=,lstart=", "-p", pids.join(",")], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 3_000, + env: { ...process.env, TZ: "UTC", LC_ALL: "C" }, }); + for (const raw of stdout.split(/\r?\n/)) { + const match = /^\s*(\d+)\s+(.+)$/.exec(raw); + if (!match) continue; + const pid = Number(match[1]); + if (out.has(pid)) out.set(pid, parseDarwinTestProcessStart(match[2]!)); + } } catch { - return false; + /* Unreadable identity keeps the live veto. */ } + return out; } -function lockIsLive(lockPath: string, owner: TestRunLockOwner): boolean { - return processIsAlive(owner.pid) || liveMemberExists(lockPath, owner); +const WINDOWS_PROCESS_QUERY_LIMITED_INFORMATION = 0x1000; +/** FILETIME ticks are 100 ns units since 1601-01-01; this is its epoch offset in ms. */ +const WINDOWS_FILETIME_EPOCH_OFFSET_MS = 11_644_473_600_000; + +interface Kernel32ProcessQuery { + OpenProcess(access: number, inherit: number, pid: number): bigint; + GetProcessTimes( + handle: bigint, + creation: BigUint64Array, + exit: BigUint64Array, + kernel: BigUint64Array, + user: BigUint64Array, + ): number; + CloseHandle(handle: bigint): number; +} + +let kernel32ProcessQuery: Kernel32ProcessQuery | null | undefined; + +function windowsKernel32ProcessQuery(): Kernel32ProcessQuery | null { + if (kernel32ProcessQuery !== undefined) return kernel32ProcessQuery; + try { + // HANDLEs travel as u64 (bigint), never as a JS number: pointer-width values + // do not all fit, and a truncated handle would name a different object. + kernel32ProcessQuery = dlopen("kernel32.dll", { + OpenProcess: { args: [FFIType.u32, FFIType.i32, FFIType.u32], returns: FFIType.u64 }, + GetProcessTimes: { args: [FFIType.u64, FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr], returns: FFIType.i32 }, + CloseHandle: { args: [FFIType.u64], returns: FFIType.i32 }, + }).symbols as Kernel32ProcessQuery; + } catch { + kernel32ProcessQuery = null; + } + return kernel32ProcessQuery; +} + +/** + * Spawn-free process start identity: OpenProcess + GetProcessTimes costs + * microseconds per pid, where a powershell.exe probe costs hundreds of + * milliseconds — and the lock is evaluated once per isolated test file. + * PROCESS_QUERY_LIMITED_INFORMATION is enough for same-user processes; a + * process we cannot open yields null, never a false identity. + */ +function kernel32ProcessStart(pid: number): string | null { + const k32 = windowsKernel32ProcessQuery(); + if (k32 === null) return null; + let handle = 0n; + try { + handle = BigInt(k32.OpenProcess(WINDOWS_PROCESS_QUERY_LIMITED_INFORMATION, 0, pid)); + if (handle === 0n) return null; + const times = new BigUint64Array(4); + if (k32.GetProcessTimes( + handle, + times.subarray(0, 1), + times.subarray(1, 2), + times.subarray(2, 3), + times.subarray(3, 4), + ) === 0) { + return null; + } + if (times[0] === 0n) return null; + return `win32:${Number(times[0]! / 10_000n) - WINDOWS_FILETIME_EPOCH_OFFSET_MS}`; + } catch { + return null; + } finally { + try { + if (handle !== 0n) k32.CloseHandle(handle); + } catch { + /* already closed */ + } + } +} + +function windowsCimProcessStartBatch(pids: readonly number[]): Map { + const out = new Map(pids.map(pid => [pid, null])); + if (pids.length === 0) return out; + try { + // One CIM query covers every candidate — serializing per-process PowerShell + // launches is what made an unbatched probe shape expensive. + const filter = pids.map(pid => `ProcessId=${pid}`).join(" OR "); + const stdout = execFileSync(resolveTrustedWindowsPowerShellExe(), [ + "-NoProfile", + "-NoLogo", + "-NonInteractive", + "-Command", + `Get-CimInstance Win32_Process -Filter "${filter}" | ForEach-Object { [string]$_.ProcessId + [char]9 + ([DateTimeOffset]$_.CreationDate.ToUniversalTime()).ToUnixTimeMilliseconds() }`, + ], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000, windowsHide: true }); + for (const line of stdout.split(/\r?\n/)) { + const tab = line.indexOf("\t"); + if (tab <= 0) continue; + const pid = Number(line.slice(0, tab)); + const identity = `win32:${line.slice(tab + 1).trim()}`; + if (out.has(pid) && validProcessStart(identity)) out.set(pid, identity); + } + } catch { + /* Unreadable identity keeps the live veto. */ + } + return out; +} + +function windowsProcessStartBatch(pids: readonly number[]): Map { + const out = new Map(); + const unprobed: number[] = []; + for (const pid of new Set(pids)) { + const start = kernel32ProcessStart(pid); + if (start === null) unprobed.push(pid); + else out.set(pid, start); + } + // One CIM query covers only what OpenProcess could not read (for example a + // process the caller lacks access to), so the spawn never runs at all in the + // common same-user case. + for (const [pid, start] of windowsCimProcessStartBatch(unprobed)) out.set(pid, start); + return out; +} + +let ownProcessStart: string | undefined; + +/** + * Start identity of the process currently holding each PID, in one platform call + * where the platform offers it: a single powershell.exe spawn or `ps` invocation + * covers whatever kernel reads could not resolve, while Linux and the Windows + * FFI path stay spawn-free. Only the caller's own start is memoized — it cannot + * change while this process lives. Foreign pids are re-probed every call because + * a cached start is exactly the evidence a recycled PID would falsify. Entries + * stay null when the platform source is unreadable — the caller turns that into + * the live veto, so a probe failure can only ever keep waiting, never reclaim a + * live run's lock. + */ +export function readTestRunProcessStartBatch(pids: readonly number[]): Map { + const out = new Map(); + const foreign: number[] = []; + for (const pid of new Set(pids)) { + if (!Number.isInteger(pid) || pid <= 0 || pid > 0xffff_ffff) out.set(pid, null); + else if (pid === process.pid && ownProcessStart !== undefined) out.set(pid, ownProcessStart); + else foreign.push(pid); + } + const probed = process.platform === "win32" + ? windowsProcessStartBatch(foreign) + : process.platform === "darwin" + ? darwinProcessStartBatch(foreign) + : process.platform === "linux" + ? new Map(foreign.map(pid => [pid, linuxProcessStart(pid)])) + : new Map(foreign.map(pid => [pid, null])); + for (const [pid, start] of probed) { + out.set(pid, start); + if (pid === process.pid && start !== null) ownProcessStart = start; + } + return out; +} + +interface LockLivenessEvidence { + startBatch: TestRunProcessStartReader; +} + +function readStart(evidence: LockLivenessEvidence, pid: number): string | undefined { + try { + const start = evidence.startBatch([pid]).get(pid); + return validProcessStart(start) ? start : undefined; + } catch { + return undefined; + } } -function registerMember(lockPath: string, owner: TestRunLockOwner, pid: number): boolean { +/** Only two valid, comparable identities can prove PID reuse. */ +function identitiesMayMatch( + recorded: string | undefined, + current: string | undefined, +): boolean { + return !recorded || !current || recorded.split(":", 1)[0] !== current.split(":", 1)[0] + || recorded === current; +} + +function ownerRecordIsLive( + owner: TestRunLockOwner, + evidence: LockLivenessEvidence, +): boolean { + const alive = processIsAlive(owner.pid); + if (alive !== true) return alive !== false; + return !owner.processStart || identitiesMayMatch(owner.processStart, readStart(evidence, owner.pid)); +} + +function liveMemberExists( + lockPath: string, + owner: TestRunLockOwner, + evidence: LockLivenessEvidence, +): boolean { + const membersDir = join(lockPath, MEMBERS_DIR); + let files: string[]; + try { + files = readdirSync(membersDir); + } catch (error) { + if (errorCode(error) === "ENOENT") return false; + throw error; + } + const suffix = `-${owner.token}`; + const candidates: { pid: number; start: string }[] = []; + for (const file of files) { + if (!file.endsWith(suffix)) continue; + const pid = Number.parseInt(file.slice(0, -suffix.length), 10); + if (!Number.isInteger(pid) || pid <= 0) continue; + const alive = processIsAlive(pid); + if (alive === false) continue; + if (alive === null) return true; + // Legacy and unreadable/malformed modern markers always veto on a live PID. + // Never interpret content in a PID-only path: an old writer appends empty. + if (!/^\d+\.v2\.[0-9a-f]{64}$/.test(file.slice(0, -suffix.length))) return true; + try { + const start: unknown = JSON.parse(readFileSync(join(membersDir, file), "utf8")); + if (!validProcessStart(start) || memberPath(lockPath, owner, pid, start) !== join(membersDir, file)) return true; + candidates.push({ pid, start }); + } catch { + return true; + } + } + if (candidates.length === 0) return false; + // One batched probe per evaluation bounds the spawn cost to a single platform + // call regardless of how many member records survived the liveness gate. + try { + const starts = evidence.startBatch([...new Set(candidates.map(candidate => candidate.pid))]); + return candidates.some(candidate => { + const current = starts.get(candidate.pid); + return identitiesMayMatch(candidate.start, validProcessStart(current) ? current : undefined); + }); + } catch { + return true; + } +} + +function lockIsLive(lockPath: string, owner: TestRunLockOwner, evidence: LockLivenessEvidence): boolean { + return ownerRecordIsLive(owner, evidence) || liveMemberExists(lockPath, owner, evidence); +} + +function registerMember(lockPath: string, owner: TestRunLockOwner, pid: number, evidence: LockLivenessEvidence): boolean { + const generation = readGeneration(lockPath); + if (!generation || !ownsLock(lockPath, owner)) return false; const membersPath = join(lockPath, MEMBERS_DIR); + const start = readStart(evidence, pid); + const path = memberPath(lockPath, owner, pid, start); try { mkdirSync(membersPath, { recursive: true, mode: 0o700 }); - writeFileSync(memberPath(lockPath, owner, pid), "", { flag: "a", mode: 0o600 }); - } catch { - return false; + try { + writeFileSync(path, start ? JSON.stringify(start) : "", { flag: "wx", mode: 0o600 }); + } catch (error) { + if (errorCode(error) !== "EEXIST") throw error; + } + } catch (error) { + if (errorCode(error) === "ENOENT") return false; + throw error; } - if (ownsLock(lockPath, owner)) return true; - rmSync(memberPath(lockPath, owner, pid), { force: true }); + if (sameGeneration(lockPath, generation) && ownsLock(lockPath, owner)) return true; + // A nonparticipating old executable may replace the canonical directory. + // Do not unlink through that changed path, even to roll back a marker. return false; } function incompleteOwnerIsRecent(lockPath: string): boolean { try { return Date.now() - statSync(lockPath).mtimeMs < INCOMPLETE_OWNER_GRACE_MS; - } catch { - return false; + } catch (error) { + if (errorCode(error) === "ENOENT") return false; + throw error; + } +} + +interface LockGeneration { dev: bigint; ino: bigint; ownerText: string | null } + +function readGeneration(lockPath: string): LockGeneration | null { + try { + const entry = lstatSync(lockPath, { bigint: true }); + if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error("Unsafe Bun test lock directory."); + let ownerText: string | null = null; + try { ownerText = readFileSync(ownerPath(lockPath), "utf8"); } + catch (error) { if (errorCode(error) !== "ENOENT") throw error; } + return { dev: entry.dev, ino: entry.ino, ownerText }; + } catch (error) { + if (errorCode(error) === "ENOENT") return null; + throw error; } } -function reclaimStaleLock(lockPath: string): boolean { +function sameGeneration(lockPath: string, expected: LockGeneration): boolean { + const current = readGeneration(lockPath); + return current?.dev === expected.dev && current.ino === expected.ino && current.ownerText === expected.ownerText; +} + +function reclaimStaleLock(lockPath: string, expected: LockGeneration): boolean { + if (!sameGeneration(lockPath, expected)) return false; const stalePath = `${lockPath}.stale-${process.pid}-${randomUUID()}`; try { renameSync(lockPath, stalePath); @@ -447,7 +779,12 @@ function reclaimStaleLock(lockPath: string): boolean { if (["ENOENT", "EACCES", "EPERM"].includes((error as NodeJS.ErrnoException).code ?? "")) return false; throw error; } - rmSync(stalePath, { recursive: true, force: true }); + if (!sameGeneration(stalePath, expected)) { + // Cannot undo an old in-flight actor's pathname race. Preserve the displaced + // object rather than deleting it or overwriting a newer canonical owner. + throw new Error("Bun test lock changed during retirement; the displaced directory was preserved."); + } + rmSync(stalePath, { recursive: true }); return true; } @@ -459,11 +796,15 @@ function ownsLock(lockPath: string, owner: TestRunLockOwner): boolean { /** * Acquire the user-scoped, machine-local OpenCodex Bun-test lock. * - * `mkdir` is the cross-platform atomic primitive. The owner PID makes a lock left by + * A stable SQLite sibling serializes updated runners' generation transitions. + * The owner PID makes a lock left by * SIGKILL recoverable, while the run ID lets every worker belonging to one bare * `bun test --parallel` invocation join the same lock without blocking its siblings. * Joiners register their own PIDs so a worker-owned lock remains live if its first - * worker exits before the rest of the pool. + * worker exits before the rest of the pool. PID liveness is bound to process + * identity when available. Legacy records and unreadable identities retain the + * live-PID veto. Old in-flight executables ignore the gate: complete them before + * upgrading to obtain the updated-runner guarantee. */ export async function acquireTestRunLock(options: AcquireTestRunLockOptions): Promise { const env = options.env ?? process.env; @@ -474,28 +815,34 @@ export async function acquireTestRunLock(options: AcquireTestRunLockOptions): Pr const usesDefaultLockPath = options.lockPath === undefined || options.validatedRuntimePath === true; const lockPath = options.lockPath ?? resolveDefaultTestRunLockPath({ env }); const ownerPid = options.ownerPid ?? process.pid; + const evidence: LockLivenessEvidence = { + startBatch: options.processStartBatch ?? readTestRunProcessStartBatch, + }; const pollMs = Math.max(1, options.pollMs ?? 5_000); const maxWaitMs = Math.max(pollMs, options.maxWaitMs ?? 45 * 60 * 1000); + // Required path/ACL setup is outside the queue-wait budget, just like default + // runtime resolution. Keep one SQLite connection across asynchronous polls. + const gate = openTestRunLockGate(lockPath); const startedAt = Date.now(); let announced = false; - if (options.joinExistingOwnerToken !== undefined) { - const current = readOwner(lockPath); - if ( - current?.runId === options.runId - && current.token === options.joinExistingOwnerToken - && lockIsLive(lockPath, current) - && registerMember(lockPath, current, process.pid) - ) { - return { acquired: false, owner: current, release() {} }; + const tryAcquire = (canRetire = true): TestRunLock | "retry" | null => { + if (options.joinExistingOwnerToken !== undefined) { + const current = readOwner(lockPath); + if ( + current?.runId === options.runId + && current.token === options.joinExistingOwnerToken + && lockIsLive(lockPath, current, evidence) + && registerMember(lockPath, current, process.pid, evidence) + ) { + return { acquired: false, owner: current, release() {} }; + } + throw new Error( + "Cannot join the wrapper-owned Bun test lock because its exact live owner no longer matches; " + + "refusing to create or reclaim an inherited path.", + ); } - throw new Error( - "Cannot join the wrapper-owned Bun test lock because its exact live owner no longer matches; " - + "refusing to create or reclaim an inherited path.", - ); - } - for (;;) { const owner: TestRunLockOwner = { version: 1, runId: options.runId, @@ -504,20 +851,37 @@ export async function acquireTestRunLock(options: AcquireTestRunLockOptions): Pr acquiredAt: new Date().toISOString(), }; try { + if (readGeneration(lockPath)) throw Object.assign(new Error("Lock already exists."), { code: "EEXIST" }); + const processStart = processIsAlive(ownerPid) === true ? readStart(evidence, ownerPid) : undefined; + if (processStart) owner.processStart = processStart; mkdirSync(lockPath, { mode: 0o700 }); + const created = readGeneration(lockPath); try { writeFileSync(ownerPath(lockPath), `${JSON.stringify(owner)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" }); } catch (error) { - rmSync(lockPath, { recursive: true, force: true }); + // No recursive deletion through the current path after publication fails. + if (created && sameGeneration(lockPath, created)) { + try { rmdirSync(lockPath); } + catch (cleanupError) { + if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(errorCode(cleanupError))) throw cleanupError; + } + } throw error; } - if (announced) options.onAcquiredAfterWait?.(Date.now() - startedAt); + const acquiredGeneration = readGeneration(lockPath); + if (!created || !acquiredGeneration || created.dev !== acquiredGeneration.dev + || created.ino !== acquiredGeneration.ino || !ownsLock(lockPath, owner)) { + throw new Error("Bun test lock generation changed during owner publication."); + } return { acquired: true, owner, release() { - if (!ownsLock(lockPath, owner)) return; - reclaimStaleLock(lockPath); + const released = withTestRunLockGate(lockPath, () => { + if (!ownsLock(lockPath, owner)) return; + reclaimStaleLock(lockPath, acquiredGeneration); + }, TEST_RUN_LOCK_RELEASE_WAIT_MS); + if (!released) throw new Error("Timed out entering the Bun test lock generation gate during release."); }, }; } catch (error) { @@ -534,27 +898,45 @@ export async function acquireTestRunLock(options: AcquireTestRunLockOptions): Pr } } + const generation = readGeneration(lockPath); const current = readOwner(lockPath); - if (current?.runId === options.runId && lockIsLive(lockPath, current)) { - if (registerMember(lockPath, current, process.pid)) { + const currentIsLive = current === null + ? incompleteOwnerIsRecent(lockPath) + : lockIsLive(lockPath, current, evidence); + if (current?.runId === options.runId && currentIsLive) { + if (registerMember(lockPath, current, process.pid, evidence)) { return { acquired: false, owner: current, release() {} }; } - continue; + return "retry"; } - const ownerIsLive = current ? lockIsLive(lockPath, current) : incompleteOwnerIsRecent(lockPath); - if (!ownerIsLive && reclaimStaleLock(lockPath)) continue; - - if (!announced) { - announced = true; - options.onWait?.(current); + if (!currentIsLive && generation) { + if (!canRetire) throw new Error("Bun test lock changed between retirement and owner publication."); + if (reclaimStaleLock(lockPath, generation)) return tryAcquire(false); } - if (Date.now() - startedAt >= maxWaitMs) { - const holder = current ? `pid ${current.pid} (run ${current.runId})` : "an initializing runner"; - throw new Error( - `timed out after ${Math.round(maxWaitMs / 1000)}s waiting for ${holder} to release ${lockPath}; ` - + `set ${TEST_RUN_NO_QUEUE_ENV}=1 only when overlapping test runners are intentional`, - ); + return null; + }; + + try { + for (;;) { + const attempt = gate.run(tryAcquire); + if (attempt?.value === "retry" && Date.now() - startedAt < maxWaitMs) continue; + if (attempt?.value && attempt.value !== "retry") { + if (attempt.value.acquired && announced) options.onAcquiredAfterWait?.(Date.now() - startedAt); + return attempt.value; + } + const current = readOwner(lockPath); + if (!announced) { + announced = true; + options.onWait?.(current); + } + if (Date.now() - startedAt >= maxWaitMs) { + const holder = current ? `pid ${current.pid} (run ${current.runId})` : "an initializing runner"; + throw new Error( + `timed out after ${Math.round(maxWaitMs / 1000)}s waiting for ${holder} to release ${lockPath}; ` + + `set ${TEST_RUN_NO_QUEUE_ENV}=1 only when overlapping test runners are intentional`, + ); + } + await Bun.sleep(pollMs); } - await Bun.sleep(pollMs); - } + } finally { gate.close(); } } diff --git a/structure/INDEX.md b/structure/INDEX.md index b3b60666d11..15084d27743 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -145,7 +145,7 @@ Background service, docs, release, and design discipline. | [`ops/docs-and-release.md`](ops/docs-and-release.md) | Docs site, workflow map, branch policy, release flow, and cross-platform CI. | | [`design-methodology.md`](design-methodology.md) | Stage ordering for new GUI, CLI, and user-facing surfaces. | | [`ops/cross-platform-ci.md`](ops/cross-platform-ci.md) | Test lanes, platform coverage, aggregate gating, and release CI proof. | -| [`ops/test-sandbox-cleanup.md`](ops/test-sandbox-cleanup.md) | Test-runner home and case-scoped lifecycle teardown ordering. | +| [`ops/test-sandbox-cleanup.md`](ops/test-sandbox-cleanup.md) | Test-runner home, case-scoped lifecycle teardown, and Bun test lock generation ownership. | ## Which doc describes which source @@ -181,6 +181,8 @@ A source area can be described by more than one doc, because these docs are orga | `gui/src/hooks/useProviderAccountPools.ts` | [`account-auto-pause.md`](account-auto-pause.md) | | `scripts/` | [`overview.md`](overview.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `scripts/generate-ocx-skill-surface.ts` | [`cli-management.md`](cli-management.md) | +| `scripts/test-run-lock-gate.ts` | [`ops/test-sandbox-cleanup.md`](ops/test-sandbox-cleanup.md) | +| `scripts/test-run-lock.ts` | [`ops/test-sandbox-cleanup.md`](ops/test-sandbox-cleanup.md) | | `skills/ocx/` | [`cli-management.md`](cli-management.md) | | `src/adapters/` | [`runtime.md`](runtime.md)
[`transports/byte-accounting.md`](transports/byte-accounting.md)
[`transports/native-reasoning-retention.md`](transports/native-reasoning-retention.md)
[`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md)
[`transports/inventory.md`](transports/inventory.md)
[`data-planes/inbound-compat.md`](data-planes/inbound-compat.md)
[`providers/anthropic-progress.md`](providers/anthropic-progress.md)
[`providers/claude-cli-replay.md`](providers/claude-cli-replay.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/cursor.md`](providers/cursor.md)
[`providers/chat-compat.md`](providers/chat-compat.md)
[`adapters/registry.md`](adapters/registry.md) | | `src/adapters/anthropic-rejection-memo.ts` | [`transports/anthropic-thinking-recovery.md`](transports/anthropic-thinking-recovery.md) | diff --git a/structure/manifest.json b/structure/manifest.json index d106e32a399..d83f54f7333 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -1094,8 +1094,10 @@ "path": "ops/test-sandbox-cleanup.md", "tier": 6, "title": "Test Sandbox Cleanup", - "scope": "Test-runner home and case-scoped lifecycle teardown ordering.", + "scope": "Test-runner home, case-scoped lifecycle teardown, and Bun test lock generation ownership.", "documents": [ + "scripts/test-run-lock.ts", + "scripts/test-run-lock-gate.ts", "tests/" ] }, diff --git a/structure/ops/test-sandbox-cleanup.md b/structure/ops/test-sandbox-cleanup.md index d32776995d8..ccc50a7057a 100644 --- a/structure/ops/test-sandbox-cleanup.md +++ b/structure/ops/test-sandbox-cleanup.md @@ -21,6 +21,35 @@ Token, SID allocation and DLL cleanup finish before the Worker posts its result `USERPROFILE` and `LOCALAPPDATA` never choose this root. Runtime canonicalization/reparse checks and the Bun test lock's required ACL hardening remain separate admission checks. +## Bun test lock generations + +`scripts/test-run-lock.ts` serializes updated runners' authoritative acquire, stale +retirement, same-run/inherited member registration and owner release through +`scripts/test-run-lock-gate.ts`. A synchronous `BEGIN IMMEDIATE` holds the gate +through liveness checks and filesystem changes; polling and callbacks run outside. +The gate is the permanent `${lockPath}.gate.sqlite` sibling. It is never renamed, +unlinked or PID/mtime-reclaimed. The existing effective-user runtime/ACL boundary +protects it; redirected and hardlinked gate paths are refused. SQLite owns its +file descriptors, with no separate filesystem open/close of the gate inode. + +Acquire retries SQLite busy within its existing bounded wait. Synchronous owner +release retries the gate in bounded slices under a ten-second elapsed deadline, +then fails without deleting the canonical lock. Process death releases the +gate's OS lock. The next runner applies +the existing conservative process-start/PID/member evidence to directory state. +Owner publication probes before exposing a new directory; failed publication +never recursively deletes through the current canonical path. Retirement checks +directory identity and owner contents before and after quarantine rename. An +unexpected displaced object is preserved rather than restored over another owner. +Only the original owner capability releases a generation; joined release is a no-op. + +The canonical name, version 1 owner records, member names and legacy live-PID veto +remain compatible. This does not stop an old in-flight executable that ignores the +gate and already decided to rename/delete a previous generation. Finish pre-update +test runners before upgrading; metadata compatibility is not mixed-version race +freedom. `tests/ci-workflows/test-run-lock-generation.test.ts` exercises private +paths, delayed generation transitions, member/release ordering and gate-holder death. + ## Sandbox lifecycle The batch runner's per-process home isolation and live-home/service-manager guards remain active diff --git a/tests/ci-workflows/test-run-lock-generation.test.ts b/tests/ci-workflows/test-run-lock-generation.test.ts new file mode 100644 index 00000000000..7d0e76ae19f --- /dev/null +++ b/tests/ci-workflows/test-run-lock-generation.test.ts @@ -0,0 +1,384 @@ +import { afterEach, expect, spyOn, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import * as fs from "node:fs"; +import { createHash, randomUUID } from "node:crypto"; +import { join } from "node:path"; +import { acquireTestRunLock } from "../../scripts/test-run-lock"; +import { TEST_RUN_LOCK_RELEASE_WAIT_MS, withTestRunLockGate } from "../../scripts/test-run-lock-gate"; +import { helperPath, repoPath } from "../helpers/repo-root"; + +const startA = "win32:1600000000000"; +const reader = (pids: readonly number[]) => new Map(pids.map(pid => [pid, startA])); + +async function fixture(body: (f: Awaited>) => Promise) { + const f = await makeFixture(); + try { await body(f); } + finally { await f.finish(); } +} + +async function makeFixture() { + fs.mkdirSync(repoPath(".tmp"), { recursive: true }); + const root = fs.mkdtempSync(repoPath(".tmp", "lock-generation-")); + const path = join(root, "suite.lock"); + fs.writeFileSync(join(root, "private-fixture"), randomUUID()); + const children: { child: ReturnType; output: Promise; startedAt: string }[] = []; + const crashes = new Set(); + const put = (role: string, suffix: string) => { + const file = join(root, `${role}.${suffix}`); + if (!fs.existsSync(file)) fs.writeFileSync(file, "true", { flag: "wx" }); + }; + const has = (role: string, suffix: string) => fs.existsSync(join(root, `${role}.${suffix}`)); + const wait = async (role: string, suffix: string) => { + const end = Date.now() + 20_000; + while (!has(role, suffix)) { + if (Date.now() >= end) throw new Error(`Private barrier timed out: ${role}.${suffix}`); + await Bun.sleep(10); + } + }; + const spawn = (role: string, runId = role) => { + const startedAt = new Date().toISOString(); + const child = Bun.spawn([process.execPath, helperPath("test-run-lock-generation-worker.ts"), root, role, runId], { + cwd: root, env: { ...process.env, OCX_TEST_NO_QUEUE: "1" }, stdout: "pipe", stderr: "pipe", + }); + const output = Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text()]); + children.push({ child, output, startedAt }); + console.info(JSON.stringify({ lockChildStart: { pid: child.pid, parentPid: process.pid, startedAt, role } })); + return child; + }; + const owner = () => JSON.parse(fs.readFileSync(join(path, "owner.json"), "utf8")); + return { root, path, put, has, wait, spawn, owner, expectCrash(pid: number) { crashes.add(pid); }, async finish() { + for (const role of ["A", "B", "member", "gate"]) { put(role, "resume"); put(role, "release"); } + for (const { child, output, startedAt } of children) { + const timeout = setTimeout(() => child.kill(), 2_000); + const exitCode = await child.exited; + clearTimeout(timeout); + const [stdout, stderr] = await output; + console.info(JSON.stringify({ lockChildTerminal: { pid: child.pid, startedAt, exitCode, stdout, stderr } })); + if (crashes.has(child.pid)) expect(exitCode).not.toBe(0); + else expect(exitCode).toBe(0); + } + // Inspect the exact private root separately from removal. No shared lock path + // or redirect is accepted, and cleanup never uses force. + expect(root.startsWith(repoPath(".tmp", "lock-generation-"))).toBe(true); + expect(fs.lstatSync(root).isSymbolicLink()).toBe(false); + for (const name of fs.readdirSync(root)) expect(fs.lstatSync(join(root, name)).isSymbolicLink()).toBe(false); + fs.rmSync(root, { recursive: true }); + expect(fs.existsSync(root)).toBe(false); + } }; +} + +for (const initializing of [false, true]) { + test(`generation gate prevents duplicate acquisition during ${initializing ? "initial probe" : "stale verdict"}`, async () => { + await fixture(async f => { + if (!initializing) { + fs.mkdirSync(f.path); + fs.writeFileSync(join(f.path, "owner.json"), JSON.stringify({ version: 1, runId: "seed", token: "seed", + pid: process.pid, acquiredAt: new Date().toISOString(), processStart: startA })); + } + f.spawn("A"); + await f.wait("A", "blocked"); + const first = fs.existsSync(f.path) ? fs.lstatSync(f.path, { bigint: true }).ino : null; + f.spawn("B"); + await f.wait("B", "waited"); + if (initializing) await Bun.sleep(10_300); // real grace, no timestamp spoofing + expect(f.has("B", "acquired")).toBe(false); + if (initializing) expect(fs.existsSync(f.path)).toBe(false); + else expect(String(fs.lstatSync(f.path, { bigint: true }).ino)).toBe(String(first)); + f.put("A", "resume"); + await f.wait("A", "acquired"); + expect(f.owner().runId).toBe("A"); + expect(f.has("B", "acquired")).toBe(false); + f.put("A", "release"); + await f.wait("A", "terminal"); + await f.wait("B", "acquired"); + expect(f.owner().runId).toBe("B"); + }); + }, 40_000); +} + +test("generation gate is recovered after its exact holder process dies", async () => { + const f = await makeFixture(); + try { + const holder = f.spawn("gate"); + await f.wait("gate", "blocked"); + const gateIdentity = fs.lstatSync(`${f.path}.gate.sqlite`, { bigint: true }).ino; + f.spawn("B"); + await f.wait("B", "waited"); + expect(f.has("B", "acquired")).toBe(false); + f.expectCrash(holder.pid); + holder.kill(); + await holder.exited; + // The intentional crash's terminal is checked here, separately from clean workers. + await f.wait("B", "acquired"); + expect(f.owner().runId).toBe("B"); + expect(fs.lstatSync(`${f.path}.gate.sqlite`, { bigint: true }).ino).toBe(gateIdentity); + } finally { await f.finish(); } +}, 30_000); + +test("member registration and obsolete owner release preserve generation ownership", async () => { + await fixture(async f => { + const owner = await acquireTestRunLock({ runId: "held", lockPath: f.path, env: {}, + processStartBatch: pids => new Map(pids.map(pid => [pid, null])) }); + f.spawn("member", "held"); + await f.wait("member", "blocked"); + let waits = 0; + await expect(acquireTestRunLock({ runId: "other", lockPath: f.path, env: {}, pollMs: 5, maxWaitMs: 80, + onWait() { waits++; }, processStartBatch: reader })).rejects.toThrow("timed out"); + expect(waits).toBe(1); + f.put("member", "resume"); + await f.wait("member", "acquired"); + expect(fs.readdirSync(join(f.path, "members")).length).toBe(1); + owner.release(); + const next = await acquireTestRunLock({ runId: "next", lockPath: f.path, env: {}, processStartBatch: reader }); + owner.release(); + expect(f.owner().token).toBe(next.owner!.token); + next.release(); + }); +}, 30_000); + +test("busy owner release is bounded and preserves the current generation", async () => { + await fixture(async f => { + const owner = await acquireTestRunLock({ runId: "held", lockPath: f.path, env: {}, processStartBatch: reader }); + f.spawn("gate"); + await f.wait("gate", "blocked"); + // Test-only stage timing for the real release() path: each release opens a + // fresh gate connection, so Database.open, the busy_timeout PRAGMA, + // BEGIN IMMEDIATE and close are separately measurable through temporary + // mocks of the public bun:sqlite surface. Budgets and SQLite behavior are + // unchanged; the diagnostic prints only when the bound is exceeded. + const stageMs: Record = {}; + const track = (stage: string, call: () => T): T => { + const t0 = Date.now(); + try { return call(); } + finally { stageMs[stage] = (stageMs[stage] ?? 0) + Date.now() - t0; } + }; + const stageOf = (sql: unknown): string => { + const s = typeof sql === "string" ? sql.trimStart() : ""; + if (s.startsWith("BEGIN")) return "begin"; + if (s.startsWith("PRAGMA")) return "pragma"; + if (s.startsWith("ROLLBACK") || s.startsWith("COMMIT")) return "endTxn"; + return "execOther"; + }; + const originalOpen = Database.open; + const openSpy = spyOn(Database, "open").mockImplementation((...args: Parameters) => track("open", () => { + const db = originalOpen.apply(Database, args); + try { + const exec = db.exec, close = db.close; + db.exec = function (this: unknown, sql: string, ...rest: unknown[]) { + return track(stageOf(sql), () => exec.call(this, sql, ...(rest as never[]))); + }; + db.close = function (this: unknown, ...rest: unknown[]) { + return track("close", () => close.apply(this, rest as never[])); + }; + } catch { + stageMs.instrumented = -1; + } + return db; + })); + const started = Date.now(); + // Capture elapsed at the exact release() boundary so post-call fixture I/O + // (owner.json read below) stays outside the measured budget. + let elapsed = -1; + const releaseOnce = () => { try { owner.release(); } finally { elapsed = Date.now() - started; } }; + try { + expect(releaseOnce).toThrow("generation gate during release"); + // Instrumentation must have observed each real stage; a silently + // uninstrumented PASS would leave the diagnostic unverified. Stages may + // legitimately measure 0ms, so only own-property + finite is required. + for (const stage of ["open", "pragma", "begin", "close"]) { + expect(Object.hasOwn(stageMs, stage) && Number.isFinite(stageMs[stage])).toBe(true); + } + // Correctness before timing: a bound violation must not hide whether the + // current generation owner survived the failed release. + expect(f.owner().token).toBe(owner.owner!.token); + expect(elapsed).toBeGreaterThanOrEqual(TEST_RUN_LOCK_RELEASE_WAIT_MS - 100); + if (elapsed >= TEST_RUN_LOCK_RELEASE_WAIT_MS + 3_000) { + console.info(JSON.stringify({ releaseStageMs: stageMs, elapsedMs: elapsed, + bun: Bun.version, platform: process.platform, arch: process.arch })); + } + expect(elapsed).toBeLessThan(TEST_RUN_LOCK_RELEASE_WAIT_MS + 3_000); + } finally { + openSpy.mockRestore(); + } + f.put("gate", "resume"); + await f.wait("gate", "terminal"); + owner.release(); + expect(fs.existsSync(f.path)).toBe(false); + }); +}, 30_000); + +// Spies installed by the controlled gate model; restored via afterEach even if +// an assertion interrupts a test. +const gateSpies: { mockRestore(): void }[] = []; +afterEach(() => { for (const spy of gateSpies.splice(0)) spy.mockRestore(); }); + +/** Intercepts busy_timeout PRAGMAs and BEGIN IMMEDIATE on gate connections + * opened during the test; the callback models the native BEGIN outcome. */ +function controlGateBegin(begin: (pragmas: number[]) => void): { pragmas: number[] } { + const pragmas: number[] = []; + const originalOpen = Database.open; + const spy = spyOn(Database, "open").mockImplementation((...args: Parameters) => { + const db = originalOpen.apply(Database, args); + const exec = db.exec; + db.exec = function (this: unknown, sql: string, ...rest: unknown[]) { + const s = sql.trimStart(); + const busy = /^PRAGMA busy_timeout\s*=\s*(\d+)/.exec(s); + if (busy) { pragmas.push(Number(busy[1])); return; } + if (s === "BEGIN IMMEDIATE") { begin(pragmas); return; } + return exec.call(this, sql, ...(rest as never[])); + }; + return db; + }); + gateSpies.push(spy); + return { pragmas }; +} + +test("release gate wait stops on the remaining elapsed budget, never a fresh full timeout", async () => { + await fixture(async f => { + let fakeNow = 1_000; + gateSpies.push(spyOn(performance, "now").mockImplementation(() => fakeNow)); + // Controlled model of a held gate: every BEGIN stays busy for 1.4x the + // requested busy_timeout — one native wait may overshoot its slice. + const { pragmas } = controlGateBegin((p) => { + fakeNow += Math.round((p.at(-1) ?? 0) * 1.4); + throw Object.assign(new Error("database is locked"), { code: "SQLITE_BUSY" }); + }); + const startedAt = fakeNow; + let ran = 0; + const result = withTestRunLockGate(f.path, () => { ran++; }, 10_000); + expect(result).toBeUndefined(); + expect(ran).toBe(0); + // Counterfactual: an un-sliced wait requests one busy_timeout of 10_000 and + // lands near 14_000ms elapsed under this model — all three assertions + // below fail on that implementation. Slices re-issue busy_timeout only + // against remaining budget, so elapsed stops near the deadline. + expect(pragmas.length).toBeGreaterThan(1); + expect(Math.max(...pragmas)).toBeLessThanOrEqual(1_000); + expect(fakeNow - startedAt).toBeLessThan(10_500); + }); +}, 30_000); + +test("gate propagates a non-busy BEGIN failure without retrying", async () => { + await fixture(async f => { + const { pragmas } = controlGateBegin(() => { + throw Object.assign(new Error("gate io failure"), { code: "EIO" }); + }); + expect(() => withTestRunLockGate(f.path, () => { throw new Error("action must not run"); }, 5_000)) + .toThrow("gate io failure"); + expect(pragmas.length).toBe(1); + }); +}, 30_000); + +test("gate normalizes a non-finite busyMs to one immediate attempt", async () => { + await fixture(async f => { + const { pragmas } = controlGateBegin(() => { + throw Object.assign(new Error("database is locked"), { code: "SQLITE_BUSY" }); + }); + for (const busyMs of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) { + // A non-finite budget must degrade to the documented busyMs=0 single + // immediate attempt; an unguarded loop would hang this test instead. + const result = withTestRunLockGate(f.path, () => { throw new Error("action must not run"); }, busyMs); + expect(result).toBeUndefined(); + } + expect(pragmas).toEqual([0, 0, 0]); + }); +}, 30_000); + +test("gate busyMs=0 declines a held gate without running the action", async () => { + await fixture(async f => { + f.spawn("gate"); + await f.wait("gate", "blocked"); + let ran = 0; + const result = withTestRunLockGate(f.path, () => { ran++; }, 0); + expect(result).toBeUndefined(); + expect(ran).toBe(0); + f.put("gate", "resume"); + await f.wait("gate", "terminal"); + }); +}, 30_000); + +test("gate executes a granted action exactly once", async () => { + await fixture(async f => { + let ran = 0; + const result = withTestRunLockGate(f.path, () => { ran++; return "held"; }, 5_000); + expect(result).toEqual({ value: "held" }); + expect(ran).toBe(1); + }); +}, 30_000); + +test("failed owner publication preserves a foreign owner installed at that path", async () => { + await fixture(async f => { + const original = fs.writeFileSync; + let injected = false; + const write = spyOn(fs, "writeFileSync").mockImplementation((...args: Parameters) => { + if (args[0] === join(f.path, "owner.json") && !injected) { + injected = true; + original(args[0], JSON.stringify({ version: 1, runId: "foreign", token: "foreign", pid: process.pid, + acquiredAt: new Date().toISOString() })); + throw Object.assign(new Error("fixture publication failure"), { code: "EACCES" }); + } + return original(...args); + }); + try { + await expect(acquireTestRunLock({ runId: "new", lockPath: f.path, env: {}, processStartBatch: reader })) + .rejects.toThrow("fixture publication failure"); + } finally { write.mockRestore(); } + expect(injected).toBe(true); + expect(f.owner().runId).toBe("foreign"); + }); +}); + +test("gate rejects redirected, hardlinked and non-file paths without touching targets", async () => { + await fixture(async f => { + const target = join(f.root, "target"); + fs.writeFileSync(target, "not a gate"); + const digest = () => createHash("sha256").update(fs.readFileSync(target)).digest("hex"); + const before = digest(); + const gate = `${f.path}.gate.sqlite`; + fs.linkSync(target, gate); + expect(() => withTestRunLockGate(f.path, () => {})).toThrow("generation gate"); + expect(digest()).toBe(before); + fs.unlinkSync(gate); + fs.mkdirSync(gate); + expect(() => withTestRunLockGate(f.path, () => {})).toThrow("generation gate"); + fs.rmdirSync(gate); + // Directory junctions do not require Windows symlink privileges. + const redirected = join(f.root, "redirected"); + fs.mkdirSync(redirected); + fs.symlinkSync(redirected, gate, process.platform === "win32" ? "junction" : "dir"); + expect(() => withTestRunLockGate(f.path, () => {})).toThrow("generation gate"); + expect(fs.readdirSync(redirected)).toEqual([]); + fs.unlinkSync(gate); + const alias = join(f.root, "parent-alias"); + fs.symlinkSync(redirected, alias, process.platform === "win32" ? "junction" : "dir"); + expect(() => withTestRunLockGate(join(alias, "suite.lock"), () => {})).toThrow("generation gate"); + expect(fs.readdirSync(redirected)).toEqual([]); + fs.unlinkSync(alias); + }); +}); + +test("corrupt gate I/O never falls back to an ungated owner", async () => { + await fixture(async f => { + fs.writeFileSync(`${f.path}.gate.sqlite`, "not a database"); + await expect(acquireTestRunLock({ runId: "blocked", lockPath: f.path, env: {}, processStartBatch: reader })) + .rejects.toThrow(); + expect(fs.existsSync(f.path)).toBe(false); + }); +}); + +test("owner metadata I/O failure refuses retirement rather than treating it as missing", async () => { + await fixture(async f => { + const owner = await acquireTestRunLock({ runId: "held", lockPath: f.path, env: {}, processStartBatch: reader }); + const original = fs.readFileSync; + const read = spyOn(fs, "readFileSync").mockImplementation(((...args: Parameters) => { + if (args[0] === join(f.path, "owner.json")) throw Object.assign(new Error("fixture I/O failure"), { code: "EIO" }); + return original(...args); + }) as typeof fs.readFileSync); + try { + await expect(acquireTestRunLock({ runId: "blocked", lockPath: f.path, env: {}, processStartBatch: reader })) + .rejects.toThrow("fixture I/O failure"); + } finally { read.mockRestore(); } + expect(f.owner().token).toBe(owner.owner!.token); + owner.release(); + }); +}); diff --git a/tests/ci-workflows/test-runner.test.ts b/tests/ci-workflows/test-runner.test.ts index b4934ee4c6d..ca1b74d4941 100644 --- a/tests/ci-workflows/test-runner.test.ts +++ b/tests/ci-workflows/test-runner.test.ts @@ -1,5 +1,6 @@ import { beforeAll, describe, expect, spyOn, test } from "bun:test"; import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; import { existsSync, lstatSync, @@ -34,6 +35,9 @@ import { import { helperPath, repoPath, repoRoot } from "../helpers/repo-root"; import { acquireTestRunLock, + parseDarwinTestProcessStart, + parseLinuxTestProcessStart, + readTestRunProcessStartBatch, resolveBareTestRunIdentity, resolveDefaultTestRunLockPath, resolveInheritedTestRunLock, @@ -1485,6 +1489,227 @@ describe("bun test user lock", () => { } }); + const startA = "win32:1600000000000"; + const startB = "win32:1600000000001"; + const identityReader = (start: string | null) => (pids: readonly number[]) => + new Map(pids.map(pid => [pid, start])); + const identityFixture = (ownerStart: unknown, memberStart?: unknown, legacyMember = false) => { + const root = mkdtempSync(join(tmpdir(), "opencodex-test-lock-")); + const lockPath = join(root, "suite.lock"); + const owner = { + version: 1, runId: "held", token: "fixture-token", acquiredAt: new Date().toISOString(), + pid: memberStart === undefined && !legacyMember ? process.pid : 2_147_483_647, + ...(ownerStart === undefined ? {} : { processStart: ownerStart }), + }; + mkdirSync(lockPath); + writeFileSync(join(lockPath, "owner.json"), JSON.stringify(owner)); + if (memberStart !== undefined || legacyMember) { + mkdirSync(join(lockPath, "members")); + const prefix = legacyMember ? String(process.pid) + : `${process.pid}.v2.${createHash("sha256").update(String(memberStart)).digest("hex")}`; + writeFileSync(join(lockPath, "members", `${prefix}-${owner.token}`), + legacyMember ? "" : JSON.stringify(memberStart)); + } + return { root, lockPath, owner }; + }; + + for (const member of [false, true]) { + test(`a reused ${member ? "member" : "owner"} PID is reclaimed by a different or same run`, async () => { + for (const runId of ["next", "held"]) { + const fixture = identityFixture(startA, member ? startA : undefined); + try { + const replacement = await acquireTestRunLock({ + runId, lockPath: fixture.lockPath, pollMs: 5, maxWaitMs: 50, + env: queuedTestEnv, processStartBatch: identityReader(startB), + }); + expect(replacement.acquired).toBe(true); + expect(replacement.owner?.processStart).toBe(startB); + replacement.release(); + expect(existsSync(fixture.lockPath)).toBe(false); + } finally { removeTreeWithRetry(fixture.root); } + } + }); + + for (const [label, recorded, current] of [ + ["matching", startA, startA], + ["unreadable", startA, null], + ["malformed current", startA, "unrecognized"], + ["malformed recorded", { unexpected: true }, startB], + ["invalid calendar", "darwin:2026-99-99T25:00:00Z", startB], + ["different platform", startA, "linux:00000000-0000-0000-0000-000000000001:10"], + ] as const) { + test(`${label} ${member ? "member" : "owner"} identity preserves the live veto`, async () => { + const fixture = identityFixture(recorded, member ? recorded : undefined); + try { + await expect(acquireTestRunLock({ + runId: "blocked", lockPath: fixture.lockPath, pollMs: 5, maxWaitMs: 20, + env: queuedTestEnv, processStartBatch: identityReader(current), + })).rejects.toThrow("timed out"); + expect(JSON.parse(readFileSync(join(fixture.lockPath, "owner.json"), "utf8"))).toEqual(fixture.owner); + } finally { removeTreeWithRetry(fixture.root); } + }); + } + } + + for (const member of [false, true]) { + test(`a legacy ${member ? "member" : "owner"} with a live PID always vetoes reclamation`, async () => { + const fixture = identityFixture(undefined, undefined, member); + try { + await expect(acquireTestRunLock({ + runId: "blocked", lockPath: fixture.lockPath, pollMs: 5, maxWaitMs: 20, + env: queuedTestEnv, processStartBatch: identityReader(startB), + })).rejects.toThrow("timed out"); + } finally { removeTreeWithRetry(fixture.root); } + }); + } + + test("modern re-registration uses a new path; legacy re-registration remains a PID veto", async () => { + const fixture = identityFixture(startA); + let current = startA; + const processStartBatch = (pids: readonly number[]) => new Map(pids.map(pid => + [pid, pid === process.ppid ? startA : current])); + // A separate live owner lets successive instances of the member PID join. + fixture.owner.pid = process.ppid; + writeFileSync(join(fixture.lockPath, "owner.json"), JSON.stringify(fixture.owner)); + const options = { runId: "held", lockPath: fixture.lockPath, pollMs: 5, maxWaitMs: 20, + env: queuedTestEnv, processStartBatch }; + try { + expect((await acquireTestRunLock(options)).acquired).toBe(false); + current = startB; + expect((await acquireTestRunLock(options)).acquired).toBe(false); + const files = readdirSync(join(fixture.lockPath, "members")); + expect(files.length).toBe(2); + for (const file of files) { + expect(Number.parseInt(file, 10)).toBe(process.pid); // old-reader compatibility + expect(file.endsWith(`-${fixture.owner.token}`)).toBe(true); + } + fixture.owner.pid = 2_147_483_647; + writeFileSync(join(fixture.lockPath, "owner.json"), JSON.stringify(fixture.owner)); + await expect(acquireTestRunLock({ ...options, runId: "blocked" })).rejects.toThrow("timed out"); + // The legacy writer appends empty to its own PID-only path. Even after all + // modern identities become stale, that registration must preserve liveness. + const legacy = join(fixture.lockPath, "members", `${process.pid}-${fixture.owner.token}`); + writeFileSync(legacy, JSON.stringify(startA)); + writeFileSync(legacy, "", { flag: "a" }); + current = "win32:1600000000002"; + await expect(acquireTestRunLock({ ...options, runId: "blocked" })).rejects.toThrow("timed out"); + expect(readFileSync(legacy, "utf8")).toBe(JSON.stringify(startA)); + } finally { removeTreeWithRetry(fixture.root); } + }); + + test("unknown registration falls back to an empty legacy member marker", async () => { + const fixture = identityFixture(undefined); + try { + await acquireTestRunLock({ + runId: "held", lockPath: fixture.lockPath, env: queuedTestEnv, + processStartBatch: identityReader(null), + }); + expect(readFileSync(join(fixture.lockPath, "members", `${process.pid}-${fixture.owner.token}`), "utf8")).toBe(""); + } finally { removeTreeWithRetry(fixture.root); } + }); + + test("a failed identity probe stays conservative", async () => { + const fixture = identityFixture(startA); + try { + await expect(acquireTestRunLock({ + runId: "blocked", lockPath: fixture.lockPath, pollMs: 5, maxWaitMs: 20, + env: queuedTestEnv, processStartBatch() { throw new Error("unreadable"); }, + })).rejects.toThrow("timed out"); + } finally { removeTreeWithRetry(fixture.root); } + }); + + test("stale verdicts are not cached across polls", async () => { + const fixture = identityFixture(startA, startB); + fixture.owner.pid = process.ppid; + writeFileSync(join(fixture.lockPath, "owner.json"), JSON.stringify(fixture.owner)); + let waited = false; + let ownerProbes = 0; + const startedAt = Date.now(); + let now = startedAt; + let freshOwnerObserved = false; + const clock = spyOn(Date, "now").mockImplementation(() => now); + // Leave the real scheduler and gate intact. If cached liveness suppresses + // the next identity probe, the existing real deadline still ends the wait. + const escapeAt = performance.now() + INTERNAL_DEADLINE_MS; + const escape = setInterval(() => { + if (performance.now() >= escapeAt) now = startedAt + 20; + }, 5); + try { + await expect(acquireTestRunLock({ + runId: "blocked", lockPath: fixture.lockPath, pollMs: 5, maxWaitMs: 20, env: queuedTestEnv, + onWait() { waited = true; now = Math.max(now, startedAt + 19); }, + processStartBatch: pids => new Map(pids.map(pid => { + if (pid === process.ppid) { + ownerProbes += 1; + if (waited) { + freshOwnerObserved = true; + now = startedAt + 20; + } + } + return [pid, waited ? startA : startB]; + })), + })).rejects.toThrow("timed out"); + expect(freshOwnerObserved).toBe(true); + expect(ownerProbes).toBeGreaterThan(1); + } finally { + clearInterval(escape); + clock.mockRestore(); + removeTreeWithRetry(fixture.root); + } + }); + + test("only ESRCH proves a PID dead", async () => { + for (const [code, member] of ["EPERM", "EACCES", "EINVAL", undefined].flatMap(code => + [[code, false], [code, true]] as const)) { + const fixture = identityFixture(startA, member ? startA : undefined); + const originalKill = process.kill.bind(process); + const kill = spyOn(process, "kill").mockImplementation((pid, signal) => { + if (member && pid !== process.pid) return originalKill(pid, signal); + throw Object.assign(new Error("probe"), { code }); + }); + try { + await expect(acquireTestRunLock({ + runId: "blocked", lockPath: fixture.lockPath, pollMs: 5, maxWaitMs: 20, + env: queuedTestEnv, processStartBatch: identityReader(startB), + })).rejects.toThrow("timed out"); + } finally { kill.mockRestore(); removeTreeWithRetry(fixture.root); } + } + }); + + test("the platform identity reader reports a stable own identity", () => { + const first = readTestRunProcessStartBatch([process.pid]).get(process.pid); + if (!["win32", "darwin", "linux"].includes(process.platform)) return; + expect(first?.startsWith(`${process.platform}:`)).toBe(true); + expect(readTestRunProcessStartBatch([process.pid]).get(process.pid)).toBe(first); + }); + + test("macOS process starts use strict English UTC tokens", () => { + expect(parseDarwinTestProcessStart("Thu Oct 22 10:03:04 2026")).toBe("darwin:2026-10-22T10:03:04Z"); + for (const value of ["Thu Oct 22 10:03:04 2026 PST", "Thu Oct 22 25:03:04 2026", + "Thu Feb 31 10:03:04 2026", "Wed Oct 22 10:03:04 2026", "jeu. oct. 22 10:03:04 2026"]) { + expect(parseDarwinTestProcessStart(value)).toBeNull(); + } + }); + + test("Linux identity uses normalized start ticks and boot UUID without a wall clock", () => { + const boot = "00000000-0000-0000-0000-000000000001"; + const stat = `123 (name with ) parentheses) S ${Array(18).fill("0").join(" ")} 000123 0`; + expect(parseLinuxTestProcessStart(stat, `${boot}\n`)).toBe(`linux:${boot}:123`); + expect(parseLinuxTestProcessStart(stat, "unreadable")).toBeNull(); + expect(parseLinuxTestProcessStart(stat.replace("000123", "invalid"), boot)).toBeNull(); + }); + + test("file timestamps never override matching process identities", async () => { + const fixture = identityFixture(startA); + try { + utimesSync(join(fixture.lockPath, "owner.json"), new Date(0), new Date(0)); + await expect(acquireTestRunLock({ + runId: "blocked", lockPath: fixture.lockPath, pollMs: 5, maxWaitMs: 20, + env: queuedTestEnv, processStartBatch: identityReader(startA), + })).rejects.toThrow("timed out"); + } finally { removeTreeWithRetry(fixture.root); } + }); + test("a live competing run fails closed after the bounded wait", async () => { const root = mkdtempSync(join(tmpdir(), "opencodex-test-lock-")); const lockPath = join(root, "suite.lock"); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 785c3f4659a..7904043106f 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1257,7 +1257,7 @@ "tencent-siliconflow-providers.test.ts": "gui", "terminal-continuation-owner-rotation.test.ts": "adapters", "terminal-guard-server.test.ts": "server", "terminal-guard.test.ts": "server", "test-home-guard.test.ts": "ci-workflows", "test-home-guard-unpreloaded.test.ts": "ci-workflows", - "test-runner.test.ts": "ci-workflows", "test-runner-bun.test.ts": "ci-workflows", + "test-run-lock-generation.test.ts": "ci-workflows", "test-runner.test.ts": "ci-workflows", "test-runner-bun.test.ts": "ci-workflows", "test-runner-spawnsync-finalizer.test.ts": "ci-workflows", "test-sandbox-cleanup.test.ts": "ci-workflows", "test-temp-quarantine.test.ts": "ci-workflows", "thought-signature-credential-scope.test.ts": "responses", "token-estimate.test.ts": "lib", "token-guardian.test.ts": "codex-integration", diff --git a/tests/helpers/nested-test-run-lock-controller.ts b/tests/helpers/nested-test-run-lock-controller.ts index 964d8e951d5..de297b95ebc 100644 --- a/tests/helpers/nested-test-run-lock-controller.ts +++ b/tests/helpers/nested-test-run-lock-controller.ts @@ -182,7 +182,7 @@ async function runNestedLiveLockController( const fixture = join(tempRoot, "nested-live-lock.test.ts"); writeFileSync(fixture, [ 'import { test } from "bun:test";', - 'import { existsSync, readFileSync } from "node:fs";', + 'import { readdirSync, readFileSync } from "node:fs";', 'import { join } from "node:path";', 'test("nested lock receipt", () => {', ' const path = process.env.OCX_TEST_RUN_LOCK_PATH ?? "";', @@ -192,7 +192,7 @@ async function runNestedLiveLockController( " sameRun: owner.runId === " + JSON.stringify(runId) + " && process.env.OCX_TEST_RUN_ID === " + JSON.stringify(runId) + ",", " sameToken: owner.token === process.env.OCX_TEST_RUN_LOCK_TOKEN,", - ' member: existsSync(join(path, "members", process.pid + "-" + owner.token)),', + ' member: readdirSync(join(path, "members")).some(file => Number.parseInt(file, 10) === process.pid && file.endsWith("-" + owner.token)),', " preloadRan: process.env.OCX_TEST_PRELOAD_PID === String(process.pid),", ' guardArmed: process.env.OCX_TEST_HOME_GUARD === "1",', " } }));", diff --git a/tests/helpers/test-run-lock-generation-worker.ts b/tests/helpers/test-run-lock-generation-worker.ts new file mode 100644 index 00000000000..e8ca3f1582e --- /dev/null +++ b/tests/helpers/test-run-lock-generation-worker.ts @@ -0,0 +1,42 @@ +import { existsSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { acquireTestRunLock } from "../../scripts/test-run-lock"; +import { withTestRunLockGate } from "../../scripts/test-run-lock-gate"; + +const [root, role, runId = role] = process.argv.slice(2) as [string, string, string?]; +if (!root || !role || !existsSync(join(root, "private-fixture"))) throw new Error("Private fixture capability missing."); +const path = join(root, "suite.lock"); +const put = (suffix: string, value: unknown = true) => + writeFileSync(join(root, `${role}.${suffix}`), JSON.stringify(value), { flag: "wx" }); +const deadline = Date.now() + 30_000; +const checkDeadline = () => { if (Date.now() >= deadline) throw new Error("Private lock fixture deadline exceeded."); }; +const waitSync = (suffix: string) => { + const pause = new Int32Array(new SharedArrayBuffer(4)); + while (!existsSync(join(root, `${role}.${suffix}`))) { checkDeadline(); Atomics.wait(pause, 0, 0, 10); } +}; +const wait = async (suffix: string) => { + while (!existsSync(join(root, `${role}.${suffix}`))) { checkDeadline(); await Bun.sleep(10); } +}; + +if (role === "gate") { + const result = withTestRunLockGate(path, () => { put("blocked"); waitSync("resume"); }); + if (!result) throw new Error("Fixture gate unexpectedly busy."); +} else { + let blocked = false; + const lock = await acquireTestRunLock({ + runId, lockPath: path, env: {}, pollMs: 10, maxWaitMs: 25_000, + onWait() { if (!existsSync(join(root, `${role}.waited`))) put("waited"); }, + processStartBatch(pids) { + if ((role === "A" || role === "member") && !blocked) { + blocked = true; + put("blocked"); + waitSync("resume"); + } + return new Map(pids.map(pid => [pid, "win32:1600000000001"])); + }, + }); + put("acquired", { acquired: lock.acquired, owner: lock.owner }); + await wait("release"); + lock.release(); +} +put("terminal", { pid: process.pid }); From 2d8463aa84a8404c9130977e9bb1cc448e883cab Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 26 Oct 2026 20:07:48 +0900 Subject: [PATCH 080/101] fix(usage): retain Kiro prompt estimates in hosted tool paths (#7744) Hosted web-search and image/video sidecar paths now record the adapter's usageLog.inputTokens estimate, so Kiro usage and daily activity include replayed prompt history. Carries #7620. Co-authored-by: Martin Becerra <197874+martinbcg@users.noreply.github.com> --- .../src/content/docs/reference/adapters.md | 4 +- scripts/test-layout/layout-adapters.json | 1 + src/server/responses/sidecar-execution.ts | 2 + structure/transports/responses.md | 2 +- tests/fixtures/test-layout-expected.json | 2 +- .../responses/responses-sidecar-usage.test.ts | 121 ++++++++++++++++++ 6 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 tests/responses/responses-sidecar-usage.test.ts diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index b4eb39b789e..b81796182f1 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -497,7 +497,9 @@ MiMo model Command Code serves. The plan's precise credit balance is retained with that identity-fenced quota reading. Separately, Kiro `meteringEvent` credit values are measured request spend: the last value within a physical response is used, and credits from separately billed sends are added. - Token usage remains estimated; credits are never inferred from tokens. + Token usage remains estimated; credits are never inferred from tokens. Requests with hosted + search or image tools retain the adapter's full prompt estimate in Usage and daily activity, + including replayed history, even when the model does not invoke those tools. - After an admitted request, reads that account's available models from the regional management service without delaying the request. Cached results add model IDs to the shipped catalog. Empty or unrecognised replies retain the shipped list and any last good account list. Model diff --git a/scripts/test-layout/layout-adapters.json b/scripts/test-layout/layout-adapters.json index e1e5a5b00ba..08f0ab47160 100644 --- a/scripts/test-layout/layout-adapters.json +++ b/scripts/test-layout/layout-adapters.json @@ -2,6 +2,7 @@ "cursor-native-grep-globs.test.ts": "providers/cursor", "tool-identity-hash.test.ts": "adapters", "eventstream-fragmentation.test.ts": "responses", + "responses-sidecar-usage.test.ts": "responses", "google-vertex-key-project.test.ts": "adapters/google", "provider-quota-abliteration.test.ts": "providers", "command-code-plan-denial.test.ts": "providers" diff --git a/src/server/responses/sidecar-execution.ts b/src/server/responses/sidecar-execution.ts index e474c4bcd5b..932e87c6080 100644 --- a/src/server/responses/sidecar-execution.ts +++ b/src/server/responses/sidecar-execution.ts @@ -585,6 +585,7 @@ export async function executeResponsesSidecars( return fetch.unpacedFetch ?? fetch; }, onRequestBuilt: request => { + if (typeof request.usageLog?.inputTokens === "number") logCtx.usageLogInputTokens = request.usageLog.inputTokens; recordAdapterReasoning(logCtx, request); recordAdapterTier(logCtx, request); }, @@ -698,6 +699,7 @@ export async function executeResponsesSidecars( abortSignal: options.abortSignal, onFirstOutput: noteSidecarOutput, onRequestBuilt: request => { + if (typeof request.usageLog?.inputTokens === "number") logCtx.usageLogInputTokens = request.usageLog.inputTokens; recordAdapterReasoning(logCtx, request); recordAdapterTier(logCtx, request); }, diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 6275ba66101..ce089db05bb 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -426,7 +426,7 @@ The continuation-note and continuation-wire tests establish prompt and wire cont | `passthrough-dispatch.ts` | Native request preparation, upstream sends and pre-commit recovery. | | `passthrough-delivery.ts`, `terminal-error-redaction.ts`, `non-replayable-error.ts` | Native HTTP/SSE/JSON delivery and terminal accounting. Marked real errors use bounded reads to retain only allowlisted error type and code, keep status and non-replayability plus `x-should-retry: false` when present, and withhold upstream body text and all other upstream headers; the client receives a fixed generic message and synthetic refusals stay distinct. SSE diagnostics and xAI tool-envelope filtering precede client delivery/continuation storage. | | `policy-refusal.ts` | Rewrites an allowlisted non-combo HTTP 403 model refusal (`isUpstreamPolicyRefusal` in `src/lib/errors.ts`) from an xAI destination only (`isXaiResponsesDestination`: api.x.ai or the Grok CLI proxy, on either wire) to an HTTP 200 Responses `incomplete` / `content_filter` payload, JSON or SSE, for both `adapter-dispatch.ts` and `passthrough-delivery.ts`. A streamed rewrite takes the turn admission lease and releases it when the body finishes, so the refusal stays inside active-turn accounting. Combo attempts keep the original 403 so failover classifies it as a hop. | -| `sidecar-execution.ts`, `sidecar-send-budget.ts` | Image/video versus web-search execution, shared rotation, and adapter-aware prepaid inference/producer ownership under the [spend contract](responses-spend.md#prepaid-initial-sends). | +| `sidecar-execution.ts`, `sidecar-send-budget.ts` | Image/video versus web-search execution, per-build adapter prompt estimates for usage logging, shared rotation, and adapter-aware prepaid inference/producer ownership under the [spend contract](responses-spend.md#prepaid-initial-sends). | | `completion-policy.ts`, `run-turn-execution.ts` | Empty-completion eligibility and adapter-owned event turns; refreshed retries reject previously failed key bindings and revalidate after each queued admission; adapter-owned CLI spawns receive the post-setup `IncomingMeta.beforePhysicalSend` fence, with [replacement-key readmission](responses-failover.md#upstream-key-attempt-accounting). | | `adapter-dispatch.ts` | Translated initial dispatch, bounded recovery (including [Anthropic thinking-signature recovery](anthropic-thinking-recovery.md)) and the shared continuation retry counter. Initial and rebuilt adapter budget refusals retain the HTTP 413 [byte-accounting contract](byte-accounting.md#request-copy-accounting); ordinary build errors remain 400. | | `adapter-continuation.ts`, `adapter-delivery.ts` | Continuation event sources and final streaming/buffered bridging; a streamed turn with a `clientEncoder` option is handed to `src/server/inference/client-encoder-delivery.ts` instead of the bridge. | diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 7904043106f..e70d51a53db 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1111,7 +1111,7 @@ "responses-compaction-policy-identity.test.ts": "responses", "responses-compaction-http-isolation.test.ts": "responses", "responses-compaction-routing.test.ts": "responses", "native-compaction-gateway.test.ts": "responses", "native-compaction-gateway-policy.test.ts": "responses", "native-compaction-provenance.test.ts": "responses", "native-compaction-oauth.test.ts": "responses", "native-compaction-gateway-credentials.test.ts": "responses", "responses-compaction.test.ts": "responses", "responses-console-go-upload-retry.test.ts": "responses", - "responses-context-overflow.test.ts": "responses", "responses-sidecar-context-overflow.test.ts": "responses", "responses-continuation-boundaries.test.ts": "responses", + "responses-context-overflow.test.ts": "responses", "responses-sidecar-context-overflow.test.ts": "responses", "responses-sidecar-usage.test.ts": "responses", "responses-continuation-boundaries.test.ts": "responses", "responses-core-modules.test.ts": "responses", "responses-custom-tool-guidance.test.ts": "responses", "responses-custom-tool-historical-replay.test.ts": "responses", "responses-custom-tool-repair-dispatch.test.ts": "responses", "responses-custom-tool-repair.test.ts": "responses", diff --git a/tests/responses/responses-sidecar-usage.test.ts b/tests/responses/responses-sidecar-usage.test.ts new file mode 100644 index 00000000000..157ac20a0e2 --- /dev/null +++ b/tests/responses/responses-sidecar-usage.test.ts @@ -0,0 +1,121 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { KIRO_COMPLETION_TOOL_NAME } from "../../src/adapters/kiro-constants"; +import { encodeMessage } from "../../src/lib/eventstream-decoder"; +import { saveCredential } from "../../src/oauth/store"; +import { handleResponses } from "../../src/server/responses/core"; +import { responseWithDeferredRequestLog } from "../../src/server/relay"; +import type { RequestLogContext } from "../../src/server/request-log"; +import { readUsageEntries, resetUsageReadCacheForTests } from "../../src/usage/log"; +import { summarizeUsage } from "../../src/usage/summary"; +import type { OcxConfig } from "../../src/types"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const originalFetch = globalThis.fetch; +const originalHome = process.env.OPENCODEX_HOME; +let home: string; +let isolated: IsolatedCodexHome; +let release: (() => void) | undefined; + +beforeEach(async () => { + home = mkdtempSync(join(tmpdir(), "ocx-sidecar-usage-")); + isolated = installIsolatedCodexHome("ocx-sidecar-usage-codex-"); + process.env.OPENCODEX_HOME = home; + release = acquireOwnedSpendHome(); + resetUsageReadCacheForTests(); + await saveCredential("kiro", { + access: "synthetic-kiro-access", refresh: "synthetic-kiro-refresh", + expires: Date.now() + 3_600_000, source: "oauth", + kiro: { apiRegion: "us-east-1" }, + }); +}); + +afterEach(() => { + release?.(); + release = undefined; + globalThis.fetch = originalFetch; + resetUsageReadCacheForTests(); + if (originalHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = originalHome; + isolated.restore(); + removeTreeWithRetry(home); +}); + +/** Build the synthetic Kiro completion frames consumed by all three dispatch fixtures. */ +function completedAnswer(): Response { + const frame = (type: string, value: Record) => encodeMessage( + { ":message-type": "event", ":event-type": type }, + new TextEncoder().encode(JSON.stringify(value)), + ); + const tool = { name: KIRO_COMPLETION_TOOL_NAME, toolUseId: "synthetic-completion" }; + return new Response(Buffer.concat([ + frame("toolUseEvent", tool), + frame("toolUseEvent", { ...tool, input: JSON.stringify({ answer: "Completed fixture answer." }) }), + frame("toolUseEvent", { ...tool, stop: true }), + ]), { headers: { "content-type": "application/vnd.amazon.eventstream" } }); +} + +describe("sidecar prompt usage accounting", () => { + for (const sidecar of ["none", "web-search", "images"] as const) { + test(`${sidecar}: persists the full Kiro prompt estimate into daily activity`, async () => { + let sends = 0; + globalThis.fetch = (async input => { + const url = input instanceof Request ? input.url : String(input); + if (!/^https:\/\/runtime\.[a-z0-9-]+\.kiro\.dev\/$/.test(url)) { + throw new Error("Unexpected network target blocked by sidecar usage fixture"); + } + sends++; + return completedAnswer(); + }) as typeof fetch; + const config = { + defaultProvider: "kiro", emptyCompletionRetry: false, + webSearchSidecar: { backend: "exa", exaApiKey: "synthetic-search-key" }, + images: { bridgeEnabled: true }, + providers: { + kiro: { adapter: "kiro", baseUrl: "https://runtime.us-east-1.kiro.dev", + authMode: "oauth", models: ["claude-opus-5.5"] }, + xai: { adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", + authMode: "key", apiKey: "synthetic-image-key", models: ["fixture"] }, + }, + } as OcxConfig; + const request = new Request("http://127.0.0.1/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "kiro/claude-opus-5.5", stream: true, store: false, + input: [ + { role: "user", content: "Earlier fixture context. ".repeat(4096) }, + { role: "assistant", content: "Earlier fixture answer." }, + { role: "user", content: "Continue." }, + ], + tools: [ + { type: "function", name: "fixture_lookup", parameters: { type: "object", properties: {} } }, + ...(sidecar === "none" ? [] : [{ type: sidecar === "web-search" ? "web_search" : "image_generation" }]), + ], + }), + }); + const log: RequestLogContext = { model: "", provider: "" }; + const started = Date.now(); + const response = await handleResponses(request, config, log); + const logged = responseWithDeferredRequestLog(response, `sidecar-usage-${sidecar}`, started, log); + expect(response.status).toBe(200); + expect(await logged.text()).toContain("Completed fixture answer."); + expect(sends).toBe(1); + + const entries = readUsageEntries(); + expect(entries).toHaveLength(1); + expect(entries[0]!.usageStatus).toBe("estimated"); + // The newest user message is tiny; the persisted request and physical attempt must + // still include the replayed prompt, just as the ordinary dispatch path does. + expect(entries[0]!.usage!.inputTokens).toBeGreaterThan(20_000); + expect(entries[0]!.attempts![0]!.usage!.inputTokens).toBeGreaterThan(20_000); + const summary = summarizeUsage(entries, "today", Date.now()); + expect(summary.summary.requests).toBe(1); + expect(summary.summary.totalTokens).toBe(entries[0]!.totalTokens!); + expect(summary.days[0]!.totalTokens).toBeGreaterThan(20_000); + expect(summary.models[0]!.inputTokens).toBeGreaterThan(20_000); + }); + } +}); From 820234bd3f43e40b2c3b98f5d32232b6234f1f89 Mon Sep 17 00:00:00 2001 From: Omar Date: Mon, 26 Oct 2026 17:16:37 +0600 Subject: [PATCH 081/101] fix(router): support Vercel JEV decisions and thinking model reasoning replay (#7574) * fix(router): support Vercel JEV decisions and thinking model reasoning replay * fix(review): address CodeRabbit feedback on Decisions format and reasoning replay * fix(review): address follow-up review findings on hostname parsing, signals, and choice notes * fix(router): return explicit fallback on URL parse failure to satisfy PR hygiene * fix(jev): reject malformed probability items in Decisions array responses * fix(jev): reject duplicate probability records in Decisions array responses * fix(openai-chat): measure unchanged passthrough bodies against maxSerializedBytes * docs: update manifest-mapped JEV and native Chat reasoning SSOT contracts and combos guide * fix(jev): select unique route answer from array answers and reject duplicate route answers * docs: cite Vercel Decisions reference and document Reflection/Stealth compatibility fallback policy * fix(openai-chat): keep tool pairs intact under Reflection placeholder replay An empty assistant turn between a call and its result received a synthesized reasoning placeholder for Reflection/Stealth models, which defeated the empty-turn drop and duplicated the call. Skip the placeholder there, add Reflection and Stealth regressions, and scope the Decisions wording in the combos guide to Vercel AI Gateway. --------- Co-authored-by: Omar <13569609+omar391@users.noreply.github.com> Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> --- docs-site/src/content/docs/guides/combos.md | 19 ++- src/adapters/openai-chat/messages.ts | 10 +- src/adapters/openai-chat/passthrough.ts | 14 +- src/adapters/openai-chat/reasoning-replay.ts | 43 +++--- src/combos/jev-decision-contract.ts | 23 +++- src/combos/jev-service-exchange.ts | 4 +- src/combos/jev.ts | 104 ++++++++++++-- src/server/chat-native-reasoning.ts | 18 ++- src/server/management/decision-discovery.ts | 14 +- structure/data-planes/inbound-compat.md | 28 +--- structure/providers/chat-compat.md | 127 ++++++++---------- structure/providers/jev-decision.md | 84 ++++++++---- structure/transports/byte-accounting.md | 12 +- .../openai-empty-assistant-history.test.ts | 24 ++++ .../chat-native-reasoning-scope.test.ts | 79 ++++++++++- tests/routing/jev-decision.test.ts | 123 +++++++++++++++++ tests/routing/jev-service-exchange.test.ts | 33 +++++ 17 files changed, 574 insertions(+), 185 deletions(-) diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index de670cbd17c..d32a8dcf122 100644 --- a/docs-site/src/content/docs/guides/combos.md +++ b/docs-site/src/content/docs/guides/combos.md @@ -455,10 +455,21 @@ Combo's `decisionProvider`: - Self-hosted services receive each target/effort option as a plain description string (for example `Target openai/gpt-5.6-sol (provider openai, model gpt-5.6-sol) with low reasoning effort.`), because Ollama accepts only string or `null` option descriptions. TypeSafe keeps - receiving the structured option objects. -- OpenCodex offers 2–26 options to a System One-compatible row. Each target contributes one option - per offered reasoning effort; use per-target `reasoningEfforts` to trim them. Fewer than 2 or - more than 26 options fail open without a request. + receiving the structured option objects. When targeting Vercel AI Gateway (`ai-gateway.vercel.sh`), + route options include both `value` and detailed `description` fields. Other self-hosted endpoints, + including a custom `/decisions` path, keep the System One request format. +- OpenCodex offers 2–26 options to a System One-compatible row (`SELF_HOSTED_MIN_OPTIONS = 2`, `SELF_HOSTED_MAX_OPTIONS = 26`). + Each target contributes one option per offered reasoning effort; use per-target `reasoningEfforts` to trim them. + Fewer than 2 or more than 26 options fail open without a request. For Vercel AI Gateway, whose + [OpenAI-compatible Decisions reference](https://vercel.com/docs/ai-gateway/sdks-and-apis/openai-decisions) + supports 1..255 choices, the upper bound expands to 255 options (`DECISIONS_MAX_OPTIONS = 255`); OpenCodex + enforces the 2-choice minimum as local routing policy to select among candidate targets. +- Vercel AI Gateway endpoints (`https://ai-gateway.vercel.sh/v1`) automatically normalize to `/decisions`. + OpenCodex projects structured conversation context into a formatted plain-text `input` string + (`Task:`, `Signals:`, `Recent Assistant:`, `Recent Tool Call:`, and `Tool Output:`). Route answers are accepted + from object (`answers.route`) or choice-array formats, resolving from either `choice` or `value`. Probability + distributions are accepted as key-value maps or probability records; duplicate choice keys or invalid distributions + strictly fail open to the first eligible target. - A cold model load can take tens of seconds, and an aborted decision request makes Ollama abandon the load. Pre-warm the model and keep it resident (`OLLAMA_KEEP_ALIVE=-1`, or `keep_alive`), and raise `decisionTimeoutMs` (1000–120000 ms, default 4000) when the service is slower than four seconds. diff --git a/src/adapters/openai-chat/messages.ts b/src/adapters/openai-chat/messages.ts index c25fe2eca78..4dfc4fe57eb 100644 --- a/src/adapters/openai-chat/messages.ts +++ b/src/adapters/openai-chat/messages.ts @@ -7,7 +7,7 @@ import { EMPTY_TOOL_OUTPUT_ANNOTATION, isWhitespaceOnlyTextPartArray } from "../ import { identifyRoutedModel } from "../identity"; import { buildNonOpenAIToolCatalogNudgeForTools, shouldInjectNonOpenAIToolCatalogNudge } from "../tool-catalog-nudge"; import { peekReasoningForCall } from "../../responses/reasoning-replay-cache"; -import { chatReasoningReplayPolicy } from "./reasoning-replay"; +import { chatReasoningReplayPolicy, isReflectionThinkingModel } from "./reasoning-replay"; import type { ClaudeThinkingProjection } from "../../lib/claude-request-projection"; import { inlineDocumentDataUrl } from "../../responses/inline-document"; import type { OcxAssistantMessage, OcxContentPart, OcxParsedRequest, OcxProviderConfig, OcxTextContent, OcxThinkingContent, OcxToolCall } from "../../types"; @@ -282,9 +282,15 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv const chatMsg: Record = { role: "assistant" }; if (textParts.length > 0) chatMsg.content = textParts.map(p => p.text).join(""); let reasoningContent = thinkingParts.map(p => p.thinking).join(""); + // Reflection/Stealth replay a placeholder even on a tool-less turn, but never on an + // empty turn inside an unfinished tool batch: that turn is dropped below, and a + // fabricated reasoning block there would split the call from its real result. + const reflectionPlaceholder = isReflectionThinkingModel(parsed.modelId) + && chatReasoningReplayPolicy(provider, parsed.modelId).placeholder + && !(pendingToolCalls.length > 0 && !chatMsg.content && toolCalls.length === 0 && thinkingParts.length === 0); if ( reasoningContent.length === 0 - && (toolCalls.length > 0 || thinkingParts.length > 0) + && (toolCalls.length > 0 || thinkingParts.length > 0 || reflectionPlaceholder) && wireSerializesThinking ) { const cached = toolCalls diff --git a/src/adapters/openai-chat/passthrough.ts b/src/adapters/openai-chat/passthrough.ts index 73823aaebcd..5a7165e5947 100644 --- a/src/adapters/openai-chat/passthrough.ts +++ b/src/adapters/openai-chat/passthrough.ts @@ -170,15 +170,13 @@ export function buildOpenAIChatPassthroughRequest( // The binding constraint is the final size base + net, so a shrinking replay may legitimately // measure a base above the cap; only a base above max - net makes the result unserializable. let baseBytes = 0; - if (replayPlan.hasChanges) { - try { - baseBytes = jsonUtf8Bytes(transformedBody(), maxSerializedBytes - replayPlan.serializedBytes); - } catch (error) { - if (isTranslatorBudgetExceededError(error)) { - throw new TranslatorBudgetExceededError("request_copies", maxSerializedBytes); - } - throw error; + try { + baseBytes = jsonUtf8Bytes(transformedBody(), maxSerializedBytes - replayPlan.serializedBytes); + } catch (error) { + if (isTranslatorBudgetExceededError(error)) { + throw new TranslatorBudgetExceededError("request_copies", maxSerializedBytes); } + throw error; } if (replayPlan.serializedBytes > maxSerializedBytes - baseBytes) { throw new TranslatorBudgetExceededError("request_copies", maxSerializedBytes); diff --git a/src/adapters/openai-chat/reasoning-replay.ts b/src/adapters/openai-chat/reasoning-replay.ts index 327ee4c1531..96b717c9a89 100644 --- a/src/adapters/openai-chat/reasoning-replay.ts +++ b/src/adapters/openai-chat/reasoning-replay.ts @@ -9,9 +9,15 @@ export const isChatRecord = (value: unknown): value is Rec => const REASONING_CONTENT_FIELD_BYTES = Buffer.byteLength('"reasoning_content":', "utf8"); -const isDeepSeekThinkingModel = (modelId: string): boolean => +export const isDeepSeekThinkingModel = (modelId: string): boolean => /(?:^|\/)deepseek-(?:v\d+(?:\.\d+)*-(?:flash|pro)|flash|pro)(?:$|[-_:])/i.test(modelId); +export const isReflectionThinkingModel = (modelId: string): boolean => + /(?:^|\/)(?:reflection|stealth)(?:$|[-_:/.])/i.test(modelId); + +export const isThinkingModelFamily = (modelId: string): boolean => + isDeepSeekThinkingModel(modelId) || isReflectionThinkingModel(modelId); + /** DeepSeek thinking tool replay is a model contract, independent of the gateway's name. */ export function chatReasoningReplayPolicy(provider: OcxProviderConfig, modelId: string): { preserve: boolean; placeholder: boolean; @@ -19,7 +25,7 @@ export function chatReasoningReplayPolicy(provider: OcxProviderConfig, modelId: // Match whole model-family tokens, including dotted versions, namespace and deployment suffixes. // Legacy chat/reasoner and distilled R1 models do not share the thinking tool-call contract. const preserve = provider.preserveReasoningContentModels === undefined - ? isDeepSeekThinkingModel(modelId) + ? isThinkingModelFamily(modelId) : modelInList(provider.preserveReasoningContentModels, modelId); return { preserve, @@ -59,21 +65,26 @@ export function planNativeChatReasoningReplay( const plans: ReplayMessagePlan[] = []; let serializedBytes = 0; for (const message of messages) { - if (!isChatRecord(message) || message.role !== "assistant" - || (typeof message.reasoning_content === "string" && message.reasoning_content.length > 0)) continue; - const calls = Array.isArray(message.tool_calls) ? message.tool_calls : []; - // DeepSeek's tools contract covers all prior assistant turns, even plain answers. - // Those turns have no call id for original replay; only the configured fallback applies. - if (calls.length === 0 && !(hasTools && isDeepSeekThinkingModel(modelId))) continue; - const cached = new Set(); - for (const tool of calls) { - if (!isChatRecord(tool) || typeof tool.id !== "string") continue; - const text = peekReasoningForCall(tool.id, scope); - if (text) cached.add(text); + if (!isChatRecord(message) || message.role !== "assistant") continue; + if (typeof message.reasoning_content === "string" && message.reasoning_content.length > 0) continue; + let parts: string[]; + if (typeof message.reasoning === "string" && message.reasoning.length > 0) { + parts = [message.reasoning]; + } else { + const calls = Array.isArray(message.tool_calls) ? message.tool_calls : []; + // DeepSeek tools contract covers prior assistant turns when tools are present. + // Thinking models like reflection/beam require reasoning replay even without tools. + if (calls.length === 0 && !(hasTools && isDeepSeekThinkingModel(modelId)) && !isReflectionThinkingModel(modelId)) continue; + const cached = new Set(); + for (const tool of calls) { + if (!isChatRecord(tool) || typeof tool.id !== "string") continue; + const text = peekReasoningForCall(tool.id, scope); + if (text) cached.add(text); + } + parts = cached.size > 0 + ? [...cached] + : policy.placeholder ? [" "] : []; } - const parts = cached.size > 0 - ? [...cached] - : policy.placeholder ? [" "] : []; if (parts.length === 0) continue; // The envelope delta around the joined value is exact for the plain JSON the Chat diff --git a/src/combos/jev-decision-contract.ts b/src/combos/jev-decision-contract.ts index 33e3aa59ee4..9afc8a574c4 100644 --- a/src/combos/jev-decision-contract.ts +++ b/src/combos/jev-decision-contract.ts @@ -38,6 +38,27 @@ export function isSystemOneEndpoint(baseUrl: string): boolean { */ export function jevDecisionEndpointUrl(baseUrl: string): string { const raw = baseUrl.trim(); - const stripped = raw.replace(/\/+$/, ""); + let stripped = raw.replace(/\/+$/, ""); + try { + const parsed = new URL(raw); + if (parsed.hostname === "ai-gateway.vercel.sh") { + if (!stripped.endsWith("/decisions")) { + stripped = stripped.replace(/\/systemone$/, "").replace(/\/+$/, "") + "/decisions"; + } + return stripped; + } + } catch { + return stripped.endsWith("/systemone") ? stripped : raw; + } return stripped.endsWith("/systemone") ? stripped : raw; } + +/** Check if endpoint path targets /decisions or Vercel AI Gateway */ +export function isDecisionsEndpoint(url: string): boolean { + try { + const parsed = new URL(url.trim()); + return parsed.hostname === "ai-gateway.vercel.sh"; + } catch { + return false; + } +} diff --git a/src/combos/jev-service-exchange.ts b/src/combos/jev-service-exchange.ts index 835141f1397..f8d955487b0 100644 --- a/src/combos/jev-service-exchange.ts +++ b/src/combos/jev-service-exchange.ts @@ -12,6 +12,7 @@ import { providerMatchesRegistryTransport } from "../providers/registry"; import type { OcxConfig, OcxProviderConfig } from "../types"; import type { JevDecision, ResolveJevDecisionOptions } from "./jev"; import { + isDecisionsEndpoint, isSystemOneEndpoint, jevDecisionEndpointUrl, JEV_DECISION_TIMEOUT_DEFAULT_MS, @@ -48,6 +49,7 @@ export type JevServiceExchangeOptions = Pick( if (endpoint === null) return { gate: "invalid" }; if (!endpoint) return { gate: "missing_key" }; - const request = prepareRequestBody(prepare, { model: endpoint.model, descriptiveCriteria: endpoint.descriptiveCriteria }); + const request = prepareRequestBody(prepare, { model: endpoint.model, descriptiveCriteria: endpoint.descriptiveCriteria, ...(isDecisionsEndpoint(endpoint.url) ? { url: endpoint.url } : {}) }); if (options.signal?.aborted) throw options.signal.reason; if ("gate" in request) return request; const requestBody = request.body; diff --git a/src/combos/jev.ts b/src/combos/jev.ts index aa4dd02eedd..256b8d38985 100644 --- a/src/combos/jev.ts +++ b/src/combos/jev.ts @@ -8,6 +8,7 @@ import { JEV_MAX_CANDIDATE_FIELD_CHARS, } from "./types"; +import { isDecisionsEndpoint } from "./jev-decision-contract"; import { exchangeJevDecision, JEV_PROVIDER_ID, @@ -25,6 +26,8 @@ export const JEV_MAX_CANDIDATES = 64; /** Ollama's System One choice questions accept 2..26 options; other services share the floor. */ const SELF_HOSTED_MIN_OPTIONS = 2; const SELF_HOSTED_MAX_OPTIONS = 26; +const DECISIONS_MIN_OPTIONS = 2; +const DECISIONS_MAX_OPTIONS = 255; const TASK_CHARS = 500; const TASK_HEAD_CHARS = 320; @@ -570,19 +573,46 @@ export function parseJevDecision( payload: unknown, candidates: readonly JevCandidate[], ): Pick { - if (!isRecord(payload) || !isRecord(payload.answers) || !isRecord(payload.answers.route)) { + if (!isRecord(payload)) { + throw new Error("missing JEV route decision"); + } + let answer: Record | undefined; + if (isRecord(payload.answers) && isRecord(payload.answers.route)) { + answer = payload.answers.route as Record; + } else if (Array.isArray(payload.answers)) { + const routeAnswers = payload.answers.filter((a): a is Record => isRecord(a) && a.name === "route"); + if (routeAnswers.length === 1) { + answer = routeAnswers[0]; + } else if (routeAnswers.length > 1) { + throw new Error("duplicate JEV route decision"); + } + } + if (!answer) { throw new Error("missing JEV route decision"); } - const answer = payload.answers.route; const options = candidateOptions(candidates); - if (typeof answer.choice !== "string" || !options.has(answer.choice)) { + const choiceValue = typeof answer.choice === "string" ? answer.choice : (typeof answer.value === "string" ? answer.value : undefined); + if (!choiceValue || !options.has(choiceValue)) { throw new Error("unknown JEV route choice"); } let chosenProbability: number | undefined; if (answer.probabilities !== undefined) { - const probabilities = answer.probabilities; - if (!isRecord(probabilities)) throw new Error("invalid JEV route probabilities"); + let probabilities: Record; + if (Array.isArray(answer.probabilities)) { + probabilities = {}; + for (const item of answer.probabilities) { + if (!isRecord(item)) throw new Error("invalid JEV route probabilities"); + const k = typeof item.choice === "string" ? item.choice : (typeof item.value === "string" ? item.value : undefined); + if (!k) throw new Error("invalid JEV route probabilities"); + if (Object.hasOwn(probabilities, k)) throw new Error("invalid JEV route probabilities"); + probabilities[k] = item.probability; + } + } else if (isRecord(answer.probabilities)) { + probabilities = answer.probabilities as Record; + } else { + throw new Error("invalid JEV route probabilities"); + } const expected = [...options.keys()].sort(); const actual = Object.keys(probabilities).sort(); if (expected.length !== actual.length || expected.some((key, index) => key !== actual[index])) { @@ -593,7 +623,7 @@ export function parseJevDecision( throw new Error("invalid JEV route probabilities"); } const numeric = values as number[]; - const selected = probabilities[answer.choice] as number; + const selected = probabilities[choiceValue] as number; if (Math.abs(numeric.reduce((sum, value) => sum + value, 0) - 1) > 0.02 || selected < Math.max(...numeric) - 1e-6) { throw new Error("inconsistent JEV route distribution"); @@ -601,7 +631,7 @@ export function parseJevDecision( chosenProbability = selected; } - const option = options.get(answer.choice)!; + const option = options.get(choiceValue)!; const confidence = typeof answer.confidence === "number" && Number.isFinite(answer.confidence) && answer.confidence >= 0 @@ -634,6 +664,31 @@ export function fallbackDecision( * Every operational or response failure returns the supplied first-eligible fallback. A caller * abort is the exception: request cancellation remains cancellation and is rethrown by identity. */ +function formatDecisionsInput(state: Record): string { + const parts: string[] = []; + if (typeof state.task === "string" && state.task.trim()) { + parts.push(`Task: ${state.task.trim()}`); + } + if (isRecord(state.signals)) { + const sigs: string[] = []; + if (state.signals.has_image === true) sigs.push("has_image"); + if (state.signals.tool_history === true) sigs.push("tool_history"); + if (sigs.length > 0) parts.push(`Signals: ${sigs.join(", ")}`); + } + if (typeof state.previous_assistant === "string" && state.previous_assistant.trim()) { + parts.push(`Recent Assistant: ${state.previous_assistant.trim()}`); + } + if (isRecord(state.step) && state.step.type === "tool_step") { + const step = state.step; + const toolName = isRecord(step.tool_call) && typeof step.tool_call.name === "string" ? step.tool_call.name : ""; + const toolOutput = typeof step.last_tool_output_tail === "string" ? step.last_tool_output_tail : ""; + if (toolName || toolOutput) { + parts.push(`Recent Tool Call: ${toolName}\nTool Output: ${toolOutput}`.trim()); + } + } + return parts.length > 0 ? parts.join("\n\n") : JSON.stringify(state); +} + export async function resolveJevDecision(options: ResolveJevDecisionOptions): Promise { const now = options.now ?? Date.now; const startedAt = now(); @@ -647,15 +702,46 @@ export async function resolveJevDecision(options: ResolveJevDecisionOptions): Pr let candidates = options.candidates; const exchanged = await exchangeJevDecision(options, (endpoint) => { - if (endpoint.descriptiveCriteria) { + const isDecisions = Boolean(endpoint.url && isDecisionsEndpoint(endpoint.url)); + const optionCount = candidateOptions(options.candidates).size; + if (isDecisions) { + if (optionCount < DECISIONS_MIN_OPTIONS) return "no_choices"; + if (optionCount > DECISIONS_MAX_OPTIONS) return "invalid"; + } else if (endpoint.descriptiveCriteria) { // Self-hosted choice questions accept 2..26 options; decide locally instead of spending a // round-trip on a request the service will refuse. - const optionCount = candidateOptions(options.candidates).size; if (optionCount < SELF_HOSTED_MIN_OPTIONS) return "no_choices"; if (optionCount > SELF_HOSTED_MAX_OPTIONS) return "invalid"; } const state = buildJevState(options.body, options.candidates); if (!hasJevDecisionState(state)) return "no_state"; + if (isDecisions && endpoint.url) { + const payload = boundedJevQuotaPayload(jevQuotaCandidates(options), rows => { + const routeOptions = candidateOptions(rows); + return JSON.stringify({ + model: endpoint.model, + input: formatDecisionsInput(state), + questions: [ + { + name: "route", + type: "choice", + instructions: "Which target AND reasoning effort together best fit the next model call? Select sufficient capability and reasoning for a correct next step while avoiding unnecessary resource use.", + choices: [...routeOptions.entries()].map(([choice, opt]) => { + const cand = rows.find(r => r.key === opt.targetKey); + const desc = criterionDescription(opt.criterion, cand?.quota); + const note = cand?.modelProfile?.trim(); + return { + value: choice, + description: note ? `${desc} (${note})` : desc, + }; + }), + }, + ], + }); + }, REQUEST_LIMIT); + candidates = payload.candidates; + return { body: payload.body }; + } const payload = boundedJevQuotaPayload(jevQuotaCandidates(options), rows => JSON.stringify({ model: endpoint.model, state, questions: buildJevRouteQuestion(rows, { descriptiveCriteria: endpoint.descriptiveCriteria }), diff --git a/src/server/chat-native-reasoning.ts b/src/server/chat-native-reasoning.ts index 1c60f1aa963..c7afeceab44 100644 --- a/src/server/chat-native-reasoning.ts +++ b/src/server/chat-native-reasoning.ts @@ -82,8 +82,13 @@ export function nativeChatReasoningCapture( return previous + fragment; } finally { reservation.release(); } }; - if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) { - const text = append(state.text, delta.reasoning_content); + const rText = typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0 + ? delta.reasoning_content + : typeof delta.reasoning === "string" && delta.reasoning.length > 0 + ? delta.reasoning + : undefined; + if (rText !== undefined) { + const text = append(state.text, rText); if (text === undefined) return; state.text = text; } @@ -121,8 +126,13 @@ export function nativeChatReasoningCapture( for (const choice of value.choices) { if (!isChatRecord(choice) || !isChatRecord(choice.message)) continue; const message = choice.message; - if (typeof message.reasoning_content !== "string" || !Array.isArray(message.tool_calls)) continue; - remember(message.reasoning_content, message.tool_calls.filter(isChatRecord) + const rText = typeof message.reasoning_content === "string" && message.reasoning_content.length > 0 + ? message.reasoning_content + : typeof message.reasoning === "string" && message.reasoning.length > 0 + ? message.reasoning + : undefined; + if (rText === undefined || !Array.isArray(message.tool_calls)) continue; + remember(rText, message.tool_calls.filter(isChatRecord) .flatMap(tool => typeof tool.id === "string" ? [tool.id] : [])); } }, diff --git a/src/server/management/decision-discovery.ts b/src/server/management/decision-discovery.ts index 8002a9553b4..b58d75108d6 100644 --- a/src/server/management/decision-discovery.ts +++ b/src/server/management/decision-discovery.ts @@ -39,7 +39,7 @@ export function isDecisionModelCandidate(row: DiscoveryModelRow, query = ""): bo const term = normalize(query); const haystack = `${row.id} ${row.owned_by ?? ""} ${row.displayName ?? ""}`.toLowerCase(); if (term.length > 0) return haystack.includes(term); - return DECISION_MODEL_HINT.test(row.id) || DECISION_MODEL_HINT.test(row.owned_by ?? ""); + return DECISION_MODEL_HINT.test(row.id) || DECISION_MODEL_HINT.test(row.owned_by ?? "") || row.id.toLowerCase().includes("laya"); } /** @@ -53,7 +53,17 @@ export function isDecisionModelCandidate(row: DiscoveryModelRow, query = ""): bo export function systemOneEndpoint(baseUrl: string | undefined): string | null { const trimmed = (baseUrl ?? "").trim().replace(/\/+$/, ""); if (!/^https?:\/\//i.test(trimmed)) return null; - return trimmed.endsWith("/systemone") ? trimmed : `${trimmed}/systemone`; + try { + const parsed = new URL(trimmed); + if (parsed.hostname === "ai-gateway.vercel.sh") { + if (trimmed.endsWith("/decisions")) return trimmed; + return `${trimmed.replace(/\/systemone$/, "").replace(/\/+$/, "")}/decisions`; + } + } catch { + return null; + } + if (trimmed.endsWith("/systemone") || trimmed.endsWith("/decisions")) return trimmed; + return `${trimmed}/systemone`; } /** Deduplicate by provider+model so a row that appears twice is probed once. */ diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 1a8472338da..4e24d97a203 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -560,31 +560,9 @@ Managed native Messages retain a coherent observed CLI identity bundle only for ### Native Chat thinking continuation -`src/adapters/openai-chat/reasoning-replay.ts` repairs assistant tool-call history missing -`reasoning_content` before native serialization. Caller-supplied nonempty text wins. The native -lane honors the same preserve/placeholder policy as translated Chat; absent preserve declarations -recognize DeepSeek Flash/Pro families across dotted versions, namespaces and gateway suffixes. -Explicit preserve lists (including `[]`) remain authoritative, and an explicit placeholder list -can disable fabricated placeholders while allowing original cached reasoning. -For tool-enabled DeepSeek requests the configured placeholder also repairs earlier assistant -answers without tool calls. Those turns have no call-id replay entry; supplied reasoning wins. -Before copying replay into the request, native passthrough measures the exact escaped-JSON net -change and checks the final request size (`baseBytes + serializedBytes`) against the fixed -`maxSerializedBytes` cap. For a changed replay, it then reserves that full size as transient -`request_copies` against the translator budget remaining after other retained copies, before -applying replay and serializing the body. The transient reservation is released in `finally`. -Exceeding either limit returns the standard 413 `translation_buffer_limit` response; history is -never silently truncated. Key rotation and retry rebuild and charge the request after releasing -the previous body, preserving the same scope and normal replay behavior within budget. - -`src/server/chat-native-reasoning.ts` records upstream JSON and fragmented SSE reasoning under -all parallel call ids, separately for each choice. Replay requires a sanitized `thread-id`, -`session_id` or `session-id` and the existing exact provider/destination/adapter/model/credential -scope. The native send rebinds that scope before every request build, including key rotation. -Missing identity, a restarted/expired/evicted cache, and another route receive only the configured -placeholder; they never recover another conversation's raw text. SSE capture retains at most -256 KiB and 64 calls within the translator budget and releases on terminal/error/cancellation. -Unfinished tool streams cannot seed replay. This leaves the native wire and response fields intact. +`src/adapters/openai-chat/reasoning-replay.ts` repairs assistant history missing `reasoning_content` before native serialization. Caller-supplied nonempty text wins. The native lane honors the same preserve/placeholder policy as translated Chat; absent preserve declarations recognize DeepSeek Flash/Pro families and Reflection/Stealth families (`isReflectionThinkingModel`). Explicit preserve lists (including `[]`) remain authoritative, and an explicit placeholder list can disable fabricated placeholders while allowing original cached reasoning. While DeepSeek models require reasoning replay for tool-enabled requests, Reflection and Stealth families require continuous reasoning replay across all prior assistant turns even when tools are absent (`hasTools: false`), synthesizing the whitespace placeholder `[" "]` when placeholder is enabled. Upstream reasoning capture in `src/server/chat-native-reasoning.ts` extracts thinking from both `reasoning_content` and `reasoning` across SSE stream deltas and buffered choices. In `src/adapters/openai-chat/passthrough.ts`, passthrough body sizing measures `baseBytes` unconditionally against `maxSerializedBytes - replayPlan.serializedBytes` via `jsonUtf8Bytes(transformedBody(), ...)`. For changed replays, it reserves `baseBytes + replayPlan.serializedBytes` as transient `request_copies` against the remaining translator budget before serializing the body, releasing the reservation in `finally`. Exceeding either limit returns HTTP 413 `translation_buffer_limit` (`TranslatorBudgetExceededError("request_copies", maxSerializedBytes)`); history is never silently truncated. Key rotation and retry rebuild and charge the request after releasing the previous body, preserving scope and normal replay behavior within budget. Model-contract evidence and compatibility policy: Verification date: 2026-10-25. The Reflection/Stealth family default is a defensive compatibility fallback policy based on observed vendor behavior: live testing against Vercel AI Gateway (`reflection-beam`, 2026-10-23) observed rejection of tool-free multi-turn requests with HTTP 400. Because this destination-specific observation is not a vendor guarantee across all possible stealth or reflection hosts, OpenCodex applies this heuristic as a fallback policy while providing explicit operator overrides via `preserveReasoningContentModels` and `requiresReasoningPlaceholderModels`. Continuous CI verification exercises hermetic local fixtures in `tests/responses/chat-native-reasoning-scope.test.ts` (pinning multi-turn non-tool replay, `reasoning` delta capture, and passthrough size checks) without live network requests. + +`src/server/chat-native-reasoning.ts` records upstream JSON and fragmented SSE reasoning under all parallel call ids, separately for each choice. Replay requires a sanitized `thread-id`, `session_id` or `session-id` and the existing exact provider/destination/adapter/model/credential scope. The native send rebinds that scope before every request build, including key rotation. Missing identity, a restarted/expired/evicted cache, and another route receive only the configured placeholder; they never recover another conversation's raw text. SSE capture retains at most 256 KiB and 64 calls within the translator budget and releases on terminal/error/cancellation. Unfinished tool streams cannot seed replay. This leaves the native wire and response fields intact. ## Codex runtime agent assertions diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 4adb8f434cd..34e9dfae339 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -330,9 +330,7 @@ Its scanner `src/adapters/openai-chat/serialized-tool-call-markup.ts` also recog Structured-call reconciliation holds a possible serialized block, resumes ordinary text delivery when the header cannot match, and removes the block only when its function name and freeform body match a structured call's parsed `input` or exact raw arguments from a declared freeform/custom tool in the same response. Valid JSON primitives, arrays, and objects can be raw -freeform input too; the declared tool name and namespace determine whether the bridge would -unwrap an alternate field, and JSON text that changes on dispatch is not treated as executed input. -Malformed JSON on an ordinary function is not raw input. +freeform input too; the declared tool name and namespace fit whether the bridge unwraps an alternate field. A block may close a freeform body with a stray `` and may omit ``, and one newline after the function header is template layout, so MiMo's echoes of those shapes match too (#5724). A forward-only next-line-header cursor keeps adjacent-block scanning linear; closing tags must match the opener’s exact ID (`` for bare blocks). @@ -485,9 +483,7 @@ grouping evidence: `devlog/_fin/260709_native_response_pattern/`. Provider policy `hideRawReasoning` hides only this raw channel (summaries keep streaming); it controls display, not confidentiality ([Responses wire shapes](../transports/responses-wire-shapes.md)). -For models requiring a reasoning placeholder, a preserved thinking-only assistant receives it even -without tools, keeping strict DeepSeek continuations valid (#5421). Chat replay policy in -`src/adapters/openai-chat/reasoning-replay.ts` honors explicit lists and otherwise recognizes DeepSeek Flash/Pro tokens across versions and gateways; [native continuation](../data-planes/inbound-compat.md#native-chat-thinking-continuation) shares this policy and fills configured placeholders on plain assistant history when DeepSeek tools require them. +For models requiring a reasoning placeholder, a preserved thinking-only assistant receives it even without tools, keeping strict DeepSeek continuations valid (#5421). Chat replay policy in `src/adapters/openai-chat/reasoning-replay.ts` honors explicit lists and otherwise recognizes thinking model families (`isThinkingModelFamily`): DeepSeek Flash/Pro (`isDeepSeekThinkingModel`) across versions and gateways, and Reflection/Stealth families (`isReflectionThinkingModel`: `/(?:^|\/)(?:reflection|stealth)(?:$|[-_:/.])/i`). Default replay policy sets `preserve: true` for all thinking model families; operators may override preservation via `provider.preserveReasoningContentModels` or placeholder synthesis via `provider.requiresReasoningPlaceholderModels`. While DeepSeek models scope reasoning replay to tool-enabled turns (`hasTools: true`), Reflection and Stealth families require reasoning replay across all prior assistant turns even when no tools are present (`hasTools: false`), synthesizing whitespace placeholder `[" "]` when `placeholder: true` and cached reasoning is absent. In `src/adapters/openai-chat/messages.ts`, `messagesToChatFormat` checks `isReflectionThinkingModel` to synthesize thinking placeholders when `wireSerializesThinking` is true. Upstream reasoning capture in `src/server/chat-native-reasoning.ts` extracts thinking from both `reasoning_content` and `reasoning` in SSE stream deltas and non-streaming choices. In `src/adapters/openai-chat/passthrough.ts`, `baseBytes` is measured unconditionally against `maxSerializedBytes - replayPlan.serializedBytes` via `jsonUtf8Bytes(transformedBody(), ...)` for both unchanged bodies and changed replays; exceeding budget raises `TranslatorBudgetExceededError("request_copies", maxSerializedBytes)` (HTTP 413), guaranteeing non-replay requests also enforce `maxSerializedBytes` and reserve transient budget. [Native continuation](../data-planes/inbound-compat.md#native-chat-thinking-continuation) shares this replay and byte-accounting policy. Model-contract evidence and compatibility policy: Verification date: 2026-10-25. The Reflection/Stealth family default is a defensive compatibility fallback policy based on observed vendor behavior: live testing against Vercel AI Gateway (`reflection-beam`, 2026-10-23) observed rejection of tool-free multi-turn requests with HTTP 400 (`Missing required parameter: 'messages[12].reasoning_content'`). Because this destination-specific observation is not a vendor guarantee across all possible stealth or reflection hosts, OpenCodex applies this heuristic as a fallback policy while providing explicit operator overrides via `preserveReasoningContentModels` and `requiresReasoningPlaceholderModels`. Continuous CI verification exercises hermetic local fixtures in `tests/responses/chat-native-reasoning-scope.test.ts` (pinning multi-turn non-tool replay, `reasoning` delta capture, and passthrough size checks) without live network requests. The process-local raw-reasoning fallback is fail-closed unless a request has an explicit client thread plus an exact provider destination, wire adapter, final model, and physical credential @@ -514,87 +510,72 @@ explicitly, and image-bearing legacy results remain unsupported rather than losi ## Chat streamed tool-call identity -`src/adapters/openai-chat.ts` retains a call's first observed non-negative safe integer -index as an alias when the call started by ID. Every present, non-null index must -be a number in that range: strings (including numeric and empty strings), booleans, -objects, arrays, negative numbers, fractions and unsafe integers terminate the stream -before any key, alias, ID or last-call matching. `Number.MAX_SAFE_INTEGER` is accepted; -larger integers are rejected because distinct wire literals can parse to the same number. -The invalid-index error releases all pending call reservations without emitting -those calls or a successful completion; invalid indexes are never treated as absent. -Only missing and null indexes are absent-index placeholders. Repeated ID, name and -argument string-field tolerance retains its existing rules. - -For valid indexes, lookup preserves direct-key precedence, then index alias, then -ID fallback. The initial key continues to own all translator budget reservations -and release; learning an alias creates no additional owner. Unassociated index-only -fragments are not guessed onto pending ID-only calls. -`tests/adapters/openai/openai-chat-parallel-stream.test.ts` covers late aliases, -parallel/colliding identities, distinct unsafe raw JSON index literals, the maximum -safe-integer boundary, invalid index types, missing/null continuations and UTF-8 -byte-limit boundaries. +`src/adapters/openai-chat.ts` retains a call's first observed non-negative safe integer index as an +alias when the call started by ID. Every present, non-null index must be a number in that range: +strings (including numeric and empty strings), booleans, objects, arrays, negative numbers, fractions, +and unsafe integers terminate the stream before any key, alias, ID or last-call matching. +`Number.MAX_SAFE_INTEGER` is accepted; larger integers are rejected because distinct wire literals can parse +to the same number. The invalid-index error releases all pending call reservations without emitting +those calls or a successful completion; invalid indexes are never treated as absent. Only missing and +null indexes are absent-index placeholders. Repeated ID, name and argument string-field tolerance retains +its existing rules. + +For valid indexes, lookup preserves direct-key precedence, then index alias, then ID fallback. The +initial key continues to own all translator budget reservations and release; learning an alias creates +no additional owner. Unassociated index-only fragments are not guessed onto pending ID-only calls. +`tests/adapters/openai/openai-chat-parallel-stream.test.ts` covers late aliases, parallel/colliding +identities, distinct unsafe raw JSON index literals, the maximum safe-integer boundary, invalid index +types, missing/null continuations and UTF-8 byte-limit boundaries. Canonical Spark Lite metadata follows the final serialized model and surviving nonempty Lite tool catalog; see [Responses transport](../transports/responses.md). Translated Chat request construction uses the [inline-image budget](../transports/streaming-health.md#translated-chat-inline-image-budget); the shared normalizer counts retained bytes even when a wire-specific drop callback keeps the image attached, rejects inputs above the safe decoded-pixel ceiling, caps native decode work process-wide, and stops queued work when the request is cancelled. ## Anthropic parallel tool use -`options.parallelToolCalls === false` maps onto Anthropic's nested -`tool_choice.disable_parallel_tool_use`. Because the flag lives inside -`tool_choice`, a request that carries only the parallel intent and no explicit -choice gets a synthesized `{type:"auto"}` so the flag has somewhere to live; -`required` maps to `{type:"any"}` and a named choice to `{type:"tool"}`, and both -accept it. `{type:"none"}` does not receive the flag because tool use is already off, -and a request with no tools on the wire emits no `tool_choice` at all. An unset or -true `parallelToolCalls` is byte-identical to previous behavior. - -The flag constrains the model's output, not execution ordering. Sequential tool use -is enforced by the caller's own loop returning each `tool_result` before issuing the -next request; this mapping does not provide that. - -Claude Opus 5.5, Fable 5.1 and Sonnet 5.5 are upstream exceptions to the forced-choice mapping: Anthropic rejects -`tool_choice: {type:"any"}` and `{type:"tool",name:...}` for that model, with or without -adaptive thinking, for all three. The adapter sends `{type:"auto"}` for those choices so the -request succeeds, but the caller's forced-tool guarantee cannot be preserved; the prompt -must provide any required tool-use instruction. Other Claude model families retain the -normal forced-choice mapping: Haiku 5.5 retains `any`/named tools, uses adaptive thinking for low..max, and reasoning none sends `disabled` without effort. Its non-default sampling fields are dropped on every path. +`options.parallelToolCalls === false` maps onto Anthropic's nested `tool_choice.disable_parallel_tool_use`. +Because the flag lives inside `tool_choice`, a request that carries only the parallel intent and no +explicit choice gets a synthesized `{type:"auto"}` so the flag has somewhere to live; `required` maps +to `{type:"any"}` and a named choice to `{type:"tool"}`, and both accept it. `{type:"none"}` does not +receive the flag because tool use is already off, and a request with no tools on the wire emits no +`tool_choice` at all. An unset or true `parallelToolCalls` is byte-identical to previous behavior. + +The flag constrains the model's output, not execution ordering. Sequential tool use is enforced by +the caller's own loop returning each `tool_result` before issuing the next request; this mapping does not provide that. + +Claude Opus 5.5, Fable 5.1 and Sonnet 5.5 are upstream exceptions to the forced-choice mapping: Anthropic +rejects `tool_choice: {type:"any"}` and `{type:"tool",name:...}` for that model, with or without adaptive +thinking, for all three. The adapter sends `{type:"auto"}` for those choices so the request succeeds, +but the caller's forced-tool guarantee cannot be preserved; the prompt must provide any required tool-use +instruction. Other Claude model families retain the normal forced-choice mapping: Haiku 5.5 retains +`any`/named tools, uses adaptive thinking for low..max, and reasoning none sends `disabled` without effort. +Its non-default sampling fields are dropped on every path. ## Unmapped modalities are recorded, not dropped -The translated Chat route has no video mapping — this adapter does not implement one. -Both serialization branches emit a bounded marker for a video part: the image-bearing -branch previously produced `{type:"text", text: undefined}`, a malformed part, and the -text-only branch joined it to `""` so a video-only or text-plus-video message was -dropped entirely. The marker names opencodex's own missing mapping; it does not assert -anything about the provider's or model's capability, which the proxy has not -established. Native Chat passthrough and Google inline video are separate routes and -are unaffected. - -`input_audio` parts with an inline WAV/MP3 data URL, or raw base64 with an explicit -`wav`/`mp3` format, are supported on the translated Chat wire. -The shared parser preserves the bytes as an `OcxAudioContent` part; the Chat serializer emits -`input_audio` for user content and promotes tool-result audio into a follow-up user message -because `role:"tool"` content is text-only. The parser stays non-throwing for native Responses -passthrough; native Responses and native Chat retain their existing wire behavior. Remote audio, -unsupported formats, the top-level `audio` spelling, developer audio, and system or assistant -audio are refused. The final adapter checks the original input under the -[untranslated-media contract](../adapters/registry.md#untranslated-input-media); translated -adapters refuse unsupported inputs while native Responses retains the original body. The -opposite Chat-to-Responses projection still rejects audio/file parts, except user-content inline -documents. No audio transport or URL fetch is added, and no client filename, payload, URL or -metadata enters errors. Admission charges supported audio by decoded size at the conservative -256-bytes-per-token proxy, not an upstream token formula. +The translated Chat route has no video mapping — this adapter does not implement one. Both serialization +branches emit a bounded marker for a video part: the image-bearing branch previously produced `{type:"text", text: undefined}`, +a malformed part, and the text-only branch joined it to `""` so a video-only or text-plus-video message +was dropped entirely. The marker names opencodex's own missing mapping; it does not assert anything about +the provider's or model's capability, which the proxy has not established. Native Chat passthrough and +Google inline video are separate routes and are unaffected. + +`input_audio` parts with an inline WAV/MP3 data URL, or raw base64 with an explicit `wav`/`mp3` format, +are supported on the translated Chat wire. The shared parser preserves the bytes as an `OcxAudioContent` part; +the Chat serializer emits `input_audio` for user content and promotes tool-result audio into a follow-up +user message because `role:"tool"` content is text-only. The parser stays non-throwing for native Responses +passthrough; native Responses and native Chat retain their existing wire behavior. Remote audio, unsupported +formats, the top-level `audio` spelling, developer audio, and system or assistant audio are refused. The final +adapter checks the original input under the [untranslated-media contract](../adapters/registry.md#untranslated-input-media); +translated adapters refuse unsupported inputs while native Responses retains the original body. The opposite +Chat-to-Responses projection still rejects audio/file parts, except user-content inline documents. No audio +transport or URL fetch is added, and no client filename, payload, URL or metadata enters errors. Admission +charges supported audio by decoded size at the conservative 256-bytes-per-token proxy, not an upstream token formula. The shared coding-agent projection in `src/adapters/coding-agent/` (CodeBuddy, Qoder) carries supported tool-result image references as real image blocks. Current tool-result prose marks supported images as `[image attached below]` and unsupported references as `[image omitted: unsupported reference]`; historical tool-result prose retains `[image]` while supported references are also emitted as image blocks. In multi-turn projection, image blocks follow conversation order — history before current. Vendor tool execution stays disabled on both -adapters. CodeBuddy refuses an unquoted, line-oriented full-width-bar DSML `calls` -container followed by a named bare or namespaced invoke control line in either output channel; it -preserves preceding answer text, never promotes vendor prose into execution authority, -and leaves discussed or quoted literals and code examples untouched. Qoder Global accepts image input through the Qoder CLI. OpenCodex forwards supported image inputs that reach the Global adapter in `src/adapters/qoder/` through the shared coding-agent projection without maintaining a per-model vision capability allowlist there. Image compatibility and any model-specific preprocessing or routing are handled by Qoder. OpenCodex does not use the static discovery fallback roster to classify per-model vision capability. Qoder CN keeps its sidecar classification and rejects residual raw-image requests that reach the adapter. OpenCodex serializes image blocks in conversation order, with history before the current turn. Because Qoder runs with `--no-session-persistence`, prior images are re-projected on later turns; in observed multi-turn image conversations, replayed images can become unreliably associated or effectively ordered relative to the historical text they originally accompanied, a known limitation of the current stateless Qoder projection path. +adapters. CodeBuddy refuses an unquoted, line-oriented full-width-bar DSML `calls` container followed by a named bare or namespaced invoke control line in either output channel; it preserves preceding answer text, never promotes vendor prose into execution authority, and leaves discussed or quoted literals and code examples untouched. Qoder Global accepts image input through the Qoder CLI. OpenCodex forwards supported image inputs that reach the Global adapter in `src/adapters/qoder/` through the shared coding-agent projection without maintaining a per-model vision capability allowlist there. Image compatibility and any model-specific preprocessing or routing are handled by Qoder. OpenCodex does not use the static discovery fallback roster to classify per-model vision capability. Qoder CN keeps its sidecar classification and rejects residual raw-image requests that reach the adapter. OpenCodex serializes image blocks in conversation order, with history before the current turn. Because Qoder runs with `--no-session-persistence`, prior images are re-projected on later turns; in observed multi-turn image conversations, replayed images can become unreliably associated or effectively ordered relative to the historical text they originally accompanied, a known limitation of the current stateless Qoder projection path. Canonical Responses identity sanitation and narrowly scoped pre-output combo recovery follow [request-local target compatibility](../runtime.md#request-local-target-compatibility); other adapter contracts remain unchanged. - Upstream API-key usage follows the [physical-attempt account attribution contract](../dashboard-and-usage.md#upstream-key-account-attribution), independently of subscription quota observations. - Unicode pattern normalization uses [copy-on-write traversal](../transports/byte-accounting.md#unicode-pattern-normalization) while preserving the existing schema and wire semantics. DeepSeek Artifact compatibility: `src/adapters/openai-chat/tool-schema.ts` omits schema `pattern` and `anyOf` constraints and strict mode for unnamespaced `Artifact` tools on `api.deepseek.com`. Surrounding properties and required fields remain; union-only nodes become unconstrained, so tool execution must validate inputs. Property names, literal defaults/examples, and caller schemas are preserved; other tools and hosts retain existing normalization. `tests/providers/deepseek-artifact-tool-schema.test.ts` checks the serialized request. diff --git a/structure/providers/jev-decision.md b/structure/providers/jev-decision.md index 3ece3fd11ca..a6d2b06b2f7 100644 --- a/structure/providers/jev-decision.md +++ b/structure/providers/jev-decision.md @@ -21,12 +21,19 @@ canonical registry transport, with `TYPESAFE_API_KEY` and the standard provider- either credential through the JEV client; a retargeted `jev` row is ignored, never a custom destination. Automated coverage mocks TypeSafe; live-key behavior is an operator smoke boundary. A Combo's `decisionProvider` selects the service: omitted or `"jev"` (stored as omission) is that canonical path with `jev-latest`; any other id must be an enabled `jev-decision` row with a full -full HTTPS decision `baseUrl` (any path) or a local HTTP `/systemone` URL and a `defaultModel`/`models[0]`, sending only its own `apiKey` (a TypeSafe -env reference or foreign keychain entry makes it unusable). The row's `authMode` attaches no other credential: only its own `apiKey` is sent, as a Bearer header. URLs with userinfo, query strings, or fragments (including an empty `?`, `#`, or `@`) are refused before a send; an HTTPS path is sent exactly as configured, while a `/systemone` path keeps its trailing-slash normalization. Shared validation rejects public HTTP hosts; the local-literal check is tested against the transport allowlist. `allowLocalCleartextPost` in +HTTPS decision `baseUrl` (any path) or a local HTTP `/systemone` URL and a `defaultModel`/`models[0]`, sending only its own `apiKey` (a TypeSafe +env reference or foreign keychain entry makes it unusable). The row's `authMode` attaches no other credential: only its own `apiKey` is sent, as a Bearer header. URLs with userinfo, query strings, or fragments (including an empty `?`, `#`, or `@`) are refused before a send; an HTTPS path is sent exactly as configured, while a `/systemone` path keeps its trailing-slash normalization. +Destinations targeting Vercel AI Gateway (`ai-gateway.vercel.sh`) normalize automatically to `/decisions` +via `jevDecisionEndpointUrl` in `src/combos/jev-decision-contract.ts` and `systemOneEndpoint` in +`src/server/management/decision-discovery.ts` (stripping trailing `/systemone` or slashes). +Shared validation rejects public HTTP hosts; the local-literal check is tested against the transport allowlist. `allowLocalCleartextPost` in `src/lib/provider-outbound.ts` admits `http:` only with the row's explicit `allowPrivateNetwork`, a `localhost`/loopback/RFC 1918/ULA host whose answers stay in that set, and no proxy. Options go out as -strings (Ollama requires them); under 2 or over 26 fail open locally (`no_choices`/`invalid`), unusable -rows reuse `missing_key`, and `decisionTimeoutMs` (1000..120000) replaces the 4 s default. +strings (Ollama requires them); under 2 fail open locally (`no_choices`). For option ceilings, Ollama +System One endpoints allow 2..26 options (`SELF_HOSTED_MIN_OPTIONS = 2`, `SELF_HOSTED_MAX_OPTIONS = 26`), +while Vercel Decisions endpoints admit 2..255 options (`DECISIONS_MIN_OPTIONS = 2`, `DECISIONS_MAX_OPTIONS = 255`). +Option counts exceeding the endpoint's maximum fail open locally (`invalid`), unusable rows reuse `missing_key`, +and `decisionTimeoutMs` (1000..120000) replaces the 4 s default. Decision backends. `src/combos/jev-dispatch.ts` derives the backend from the Combo and never stores it: `decisionModel` set means `model`, a `decisionProvider` other than `jev` means `systemone`, and @@ -50,8 +57,8 @@ PATCH cannot turn a referenced row into one). `src/server/management/decision-ro body's `decisionProvider` / `decisionModel` select the method, none means TypeSafe; `comboId` only scopes the recursion rules, and a disabled, model-less or non-System-One row is refused by name), and `GET /api/combos/decision-discovery`, read-only System One and catalog hints built by -`src/server/management/decision-discovery.ts`. Persisted decisions carry an optional `backend`, and -the usage aggregate reports per-backend counts and latency with older rows as `unknown`. +`src/server/management/decision-discovery.ts` (identifying candidate models including Apple Silicon `"laya"` models). +Persisted decisions carry an optional `backend`, and the usage aggregate reports per-backend counts and latency with older rows as `unknown`. `src/combos/jev.ts` extracts bounded user-task, previous-assistant, and latest-tool-output text plus the tool name and boolean signals; raw image data, tool arguments, encrypted reasoning, headers, and @@ -59,28 +66,49 @@ the JEV credential are excluded. All three text samples omit recognized Codex pr and Claude Code `` blocks before clipping, including nested and unclosed blocks. Only an envelope-only `codex_internal_context` goal outside a reminder may supply a fallback task; reminder-only text supplies no task. Reminder-free inputs keep their existing sampling behavior. -It owns the joint target/effort choice map and strict answer -validation, including the complete probability distribution. The service exchange owns the canonical -`jev-latest` destination, the self-hosted URL (shared `jevDecisionEndpointUrl`: an HTTPS path as -configured, `/systemone` trailing slashes normalized), credentials, 64 KiB serialized request and response caps, default -four-second deadline, no-redirect policy, and caller-cancellation ownership. It rejects an already -aborted caller by reason identity before endpoint, credential or preparation work. It checks again -after endpoint resolution and preparation (including failures), POST, redirect inspection, HTTP -error-body cleanup and response reads, and when parsing returns or throws; caller cancellation takes -precedence over the local outcome at those checkpoints. HTTP error-body cancellation is best effort -and never awaited. The separate decision deadline remains a `timeout` gate. Its -request builder receives only `model` and `descriptiveCriteria` after authorization/credential -resolution; its answer parser runs inside the cancellation boundary. Without caller cancellation, -question-specific local refusals and invalid answers retain their existing gates. `tests/routing/jev-service-exchange.test.ts` exercises this -seam independently of route questions; `tests/routing/jev-typesafe-golden.test.ts` keeps the -unchanged route bytes authoritative. Missing credentials or safe state, transport failures, and -invalid answers fail open to the first eligible target; no response can escape the configured choice -map. -The direct TypeSafe and System One decision destinations are checked against the parent API key's -resolved provider/model scope before reading decision credentials or extracting state. A denied -optional decision uses the existing fail-open inference target without sending a decision request; -management probes and unrestricted admissions retain their existing behavior. -Telemetry never retains extracted state or credentials. +When routing to a Decisions endpoint (`isDecisionsEndpoint`), `formatDecisionsInput` projects this state into +a formatted plain-text `input` string: `Task:` with user task text, `Signals:` (`has_image`, `tool_history` +when present), `Recent Assistant:` prior assistant text, and `Recent Tool Call:` / `Tool Output:` from step +history. Route choices are sent in `questions` as `{ value, description }` entries, joining target criterion +descriptions with operator `modelProfile` notes when configured. + +Answer selection and probability validation (`parseJevDecision` in `src/combos/jev.ts`): +The parser accepts answers as an object (`payload.answers.route`) or an array (`payload.answers`), +selecting the unique route answer (`name === "route"`) while ignoring unrelated choices and throwing on +duplicate route answers. Selected route values read from `choice` or `value`. The probability +distribution accepts an object mapping option keys to numbers or an array of `{ choice | value, probability }` +entries. Array distributions strictly refuse duplicate choice keys (`invalid JEV route probabilities`). +Parsing validates that: (1) distribution keys match expected candidate choices exactly, (2) probabilities are +finite numbers between 0 and 1, (3) total probability sums to 1.00 ± 0.02, and (4) the selected option holds +the maximum probability (within 1e-6). Missing route answers, unknown choices, malformed distributions, duplicate +route answers or array keys, or inconsistent totals throw and fail open to the first eligible target. + +The service exchange owns the canonical `jev-latest` destination, the self-hosted URL (shared +`jevDecisionEndpointUrl`: an HTTPS path as configured with Vercel `/decisions` rewrite, `/systemone` trailing slashes normalized), +credentials, 64 KiB serialized request and response caps, default four-second deadline, no-redirect policy, +and caller-cancellation ownership. It rejects an already aborted caller by reason identity before endpoint, +credential or preparation work. It checks again after endpoint resolution and preparation (including failures), +POST, redirect inspection, HTTP error-body cleanup and response reads, and when parsing returns or throws; +caller cancellation takes precedence over the local outcome at those checkpoints. HTTP error-body cancellation +is best effort and never awaited. The separate decision deadline remains a `timeout` gate. Its request builder +receives `model`, `descriptiveCriteria`, and optional canonical gateway `url` after authorization/credential resolution; its answer parser +runs inside the cancellation boundary. Without caller cancellation, question-specific local refusals and +invalid answers retain their existing gates. `tests/routing/jev-service-exchange.test.ts` exercises this seam +independently of route questions; `tests/routing/jev-typesafe-golden.test.ts` keeps the unchanged route bytes authoritative. +Missing credentials or safe state, transport failures, and invalid answers fail open to the first eligible +target; no response can escape the configured choice map. +The direct TypeSafe and System One decision destinations are checked against the parent API key's resolved +provider/model scope before reading decision credentials or extracting state. A denied optional decision uses +the existing fail-open inference target without sending a decision request; management probes and unrestricted +admissions retain their existing behavior. Telemetry never retains extracted state or credentials. + +Primary endpoint/model-contract evidence and verification: +Verification date: 2026-10-25. The [official Vercel OpenAI-compatible Decisions reference](https://vercel.com/docs/ai-gateway/sdks-and-apis/openai-decisions) +(checked 2026-10-25) specifies the `/v1/decisions` contract, string input, named array questions/answers, and the value/probability schema. +The upstream API supports a choice range of 1..255; OpenCodex enforces a 2-choice minimum as local routing policy since routing requires +at least two distinct targets. Continuous CI verification exercises hermetic local fixtures in `tests/routing/jev-decision.test.ts` and +`tests/routing/jev-service-exchange.test.ts` (covering `/decisions` endpoint rewriting, context projection, 2..255 option bounds, +array/object answers, and duplicate-probability refusal) without live vendor network calls. ## Loaded advisory quota diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index b9fce3b32c6..13ae6cd51e5 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -362,17 +362,7 @@ The [compaction routing override](responses-failover.md#compaction-routing-overr `src/lib/sse-decoder.ts` recognizes CR, LF and CRLF line endings, including CRLF split between fetch chunks. Delimiters are consumed before field retention; event/comment ordering, EOF dispatch and translator-budget release remain shared across all three forms. The delimiter search keeps native `indexOf` cursors for the next CR and LF in each decoded chunk, so scanning stays linear for every framing. -Native Chat's optional reasoning capture (`src/server/chat-native-reasoning.ts`) charges retained -reasoning and tool ids before concatenation and reserves the previous string during replacement. -Its separate 256 KiB/64-call bound drops the fallback capture without interrupting the wire relay. -Terminal, error and cancellation paths release collector retention; the shared replay cache owns -its own bounded text after insertion. For a changed replay, native Chat measures the exact -escaped-JSON net change and checks the final request size (`baseBytes + serializedBytes`) -against the fixed `maxSerializedBytes` cap before expansion. It then reserves that full size as -transient `request_copies` against the remaining translator budget before applying replay and -serializing the request, releasing the reservation in `finally`. Exceeding either limit returns -413 without truncating history. The post-build request charge remains owned and released by -the existing retry/key-rotation lifecycle. The [native continuation contract](../data-planes/inbound-compat.md#native-chat-thinking-continuation) defines replay scope and cache-miss behavior. +Native Chat's optional reasoning capture (`src/server/chat-native-reasoning.ts`) captures thinking from both `reasoning_content` and `reasoning` properties across SSE stream deltas and non-streaming choices. It charges retained reasoning and tool ids before concatenation and reserves the previous string during replacement. Its separate 256 KiB/64-call bound drops the fallback capture without interrupting the wire relay. Terminal, error and cancellation paths release collector retention; the shared replay cache owns its own bounded text after insertion. In `src/adapters/openai-chat/passthrough.ts`, passthrough body sizing measures `baseBytes` unconditionally against `maxSerializedBytes - replayPlan.serializedBytes` via `jsonUtf8Bytes(transformedBody(), maxSerializedBytes - replayPlan.serializedBytes)` for both unchanged bodies and changed replays. Exceeding the remaining serialized byte budget immediately throws `TranslatorBudgetExceededError("request_copies", maxSerializedBytes)` (HTTP 413), enforcing `maxSerializedBytes` even on non-replay requests without eager string serialization. For a changed replay, native Chat measures the exact escaped-JSON net change, checks final request size (`baseBytes + serializedBytes`) against `maxSerializedBytes`, and reserves that full size as transient `request_copies` against the remaining translator budget before applying replay and serializing the request, releasing the reservation in `finally`. Exceeding either limit returns 413 without truncating history. The post-build request charge remains owned and released by the existing retry/key-rotation lifecycle. The [native continuation contract](../data-planes/inbound-compat.md#native-chat-thinking-continuation) defines replay scope and cache-miss behavior. Model-contract evidence and compatibility policy: Verification date: 2026-10-25. The Reflection/Stealth family default is a defensive compatibility fallback policy based on observed vendor behavior: live testing against Vercel AI Gateway (`reflection-beam`, 2026-10-23) observed rejection of tool-free multi-turn requests with HTTP 400. Because this destination-specific observation is not a vendor guarantee across all possible stealth or reflection hosts, OpenCodex applies this heuristic as a fallback policy while providing explicit operator overrides via `preserveReasoningContentModels` and `requiresReasoningPlaceholderModels`. Continuous CI verification exercises hermetic local fixtures in `tests/responses/chat-native-reasoning-scope.test.ts` (pinning multi-turn non-tool replay, `reasoning` delta capture, and passthrough size checks) without live network requests. The Opus 5.5 progress normalizer in `src/adapters/anthropic-progress.ts` reserves its one-block thinking/signature buffer before concatenation, replaces signatures rather than appending, and diff --git a/tests/adapters/openai/openai-empty-assistant-history.test.ts b/tests/adapters/openai/openai-empty-assistant-history.test.ts index ffb03751edb..147247589a8 100644 --- a/tests/adapters/openai/openai-empty-assistant-history.test.ts +++ b/tests/adapters/openai/openai-empty-assistant-history.test.ts @@ -148,3 +148,27 @@ describe("translated Chat empty assistant projection", () => { expect(body.messages[3].content).toBe(" \n"); }); }); + +describe("Reflection/Stealth placeholder replay keeps tool pairs intact (#7574 review)", () => { + test.each(["reflection/beam", "stealth/beam"])("%s: an empty assistant between a call and its result is dropped", model => { + const parsed = parseRequest({ model, input: [ + { type: "function_call", call_id: "call_1", name: "read_file", arguments: "{}" }, + assistant([{ type: "output_text", text: "" }]), + { type: "function_call_output", call_id: "call_1", output: "result" }, + user, + ] }); + const adapter = createOpenAIChatAdapter({ adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1", apiKey: "fixture" }); + const messages = JSON.parse(adapter.buildRequest(parsed).body).messages as Array>; + const calls = messages.filter(m => m.role === "assistant" && m.tool_calls?.length); + expect(calls).toHaveLength(1); + expect(calls[0]!.tool_calls[0].id).toBe("call_1"); + expect(messages.filter(m => m.role === "tool")).toEqual([{ role: "tool", tool_call_id: "call_1", content: "result" }]); + }); + + test("a standalone empty Reflection assistant still receives its placeholder", () => { + const parsed = parseRequest({ model: "reflection/beam", input: [user, assistant([{ type: "output_text", text: "" }]), user] }); + const adapter = createOpenAIChatAdapter({ adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1", apiKey: "fixture" }); + const messages = JSON.parse(adapter.buildRequest(parsed).body).messages as Array>; + expect(messages.some(m => m.role === "assistant" && typeof m.reasoning_content === "string")).toBe(true); + }); +}); diff --git a/tests/responses/chat-native-reasoning-scope.test.ts b/tests/responses/chat-native-reasoning-scope.test.ts index eb1295bed48..c996df1ea02 100644 --- a/tests/responses/chat-native-reasoning-scope.test.ts +++ b/tests/responses/chat-native-reasoning-scope.test.ts @@ -26,7 +26,8 @@ afterEach(() => clearReasoningReplayCacheForTests()); describe("native Chat reasoning policy and physical replay ownership", () => { test("family detection is version-agnostic and independent of provider names", () => { for (const model of [MODEL, "deepseek-v4-flash", "deepseek-v15.12-pro-20261015", - "deepseek/deepseek-v4.1-flash-free", "gateway/DeepSeek-V5.2-Flash:thinking", "deepseek-flash"]) { + "deepseek/deepseek-v4.1-flash-free", "gateway/DeepSeek-V5.2-Flash:thinking", "deepseek-flash", + "reflection-beam", "reflection/beam", "command-code/reflection-beam:free", "stealth-glyph-cluster:free"]) { expect(chatReasoningReplayPolicy(PROVIDER, model)).toEqual({ preserve: true, placeholder: true }); } for (const model of ["deepseek-chat", "deepseek-reasoner", "deepseek-r1-distill-llama", @@ -178,8 +179,29 @@ describe("native Chat reasoning policy and physical replay ownership", () => { expect(() => planNativeChatReasoningReplay(absent, PROVIDER, MODEL, owner, false, max)) .toThrow(expect.objectContaining({ code: "translation_buffer_limit", limitBytes: max })); } + // An assistant message carrying reasoning instead of tool_calls also pays the envelope delta. + const withReasoning = [{ role: "assistant", content: null, reasoning: "aa" }]; + expect(planNativeChatReasoningReplay(withReasoning, PROVIDER, MODEL, owner, false, 25).serializedBytes).toBe(25); + for (const max of [1, 10, 24]) { + expect(() => planNativeChatReasoningReplay(withReasoning, PROVIDER, MODEL, owner, false, max)) + .toThrow(expect.objectContaining({ code: "translation_buffer_limit", limitBytes: max })); + } budget.dispose(); }); + test("enforces maxSerializedBytes even when replayPlan has no changes", () => { + const raw = { messages: [{ role: "user", content: "hello world" }] }; + const exactBytes = Buffer.byteLength( + buildOpenAIChatPassthroughRequest(PROVIDER, raw, MODEL, false).body, + ); + expect( + JSON.parse( + buildOpenAIChatPassthroughRequest(PROVIDER, raw, MODEL, false, undefined, undefined, "fixture", undefined, exactBytes).body, + ).messages[0].content, + ).toBe("hello world"); + expect(() => + buildOpenAIChatPassthroughRequest(PROVIDER, raw, MODEL, false, undefined, undefined, "fixture", undefined, exactBytes - 1), + ).toThrow(expect.objectContaining({ code: "translation_buffer_limit" })); + }); test("plain-answer placeholders require DeepSeek, tools and both policy opt-ins", () => { const messages = [{ role: "assistant", content: "Earlier answer" }]; const tools = [{ type: "function", function: { name: "inspect", parameters: { type: "object", properties: {} } } }]; @@ -195,6 +217,61 @@ describe("native Chat reasoning policy and physical replay ownership", () => { } expect(messages).toEqual([{ role: "assistant", content: "Earlier answer" }]); }); + test("reflection models replay placeholder on prior assistant turns even without tools", () => { + const messages = [{ role: "assistant", content: "Earlier plain answer" }]; + const request = buildOpenAIChatPassthroughRequest(PROVIDER, { messages, tools: [] }, "reflection/beam", false); + expect(JSON.parse(request.body).messages[0].reasoning_content).toBe(" "); + }); + test("replays captured reasoning from delta.reasoning and message.reasoning rather than placeholder", async () => { + const replayModel = "reflection/beam"; + const owner = scope("fixture-thread", PROVIDER, "fixture", replayModel); + const budget = createTranslatorBudget(); + const capture = nativeChatReasoningCapture(PROVIDER, replayModel, owner, budget); + + capture.json({ choices: [ + { message: { reasoning: "from-json-message-reasoning", tool_calls: [tool("call_json")] } }, + ] }); + + const frames = [ + { choices: [{ index: 0, delta: { reasoning: "from-sse-delta-reasoning", tool_calls: [{ index: 0, id: "call_sse" }] } }] }, + ]; + const source = new Response(frames.map(v => `data: ${JSON.stringify(v)}\n\n`).join("") + "data: [DONE]\n\n").body!; + const stream = nativeChatSse(source, { + requestedModel: replayModel, + translatorBudget: budget, + signal: new AbortController().signal, + onUsage() {}, + onChunk: capture.observe, + onTerminal: s => capture.finish(s < 400), + onCancel: () => capture.finish(false), + }); + await new Response(stream).text(); + + const reqJson = buildOpenAIChatPassthroughRequest( + PROVIDER, + { messages: [{ role: "assistant", content: null, tool_calls: [tool("call_json")] }] }, + "reflection/beam", + false, + undefined, + undefined, + "test-session", + owner, + ); + expect(JSON.parse(reqJson.body).messages[0].reasoning_content).toBe("from-json-message-reasoning"); + + const reqSse = buildOpenAIChatPassthroughRequest( + PROVIDER, + { messages: [{ role: "assistant", content: null, tool_calls: [tool("call_sse")] }] }, + "reflection/beam", + false, + undefined, + undefined, + "test-session", + owner, + ); + expect(JSON.parse(reqSse.body).messages[0].reasoning_content).toBe("from-sse-delta-reasoning"); + budget.dispose(); + }); }); describe("native SSE reasoning capture lifecycle", () => { diff --git a/tests/routing/jev-decision.test.ts b/tests/routing/jev-decision.test.ts index f1a5b228c80..ff46caa26b2 100644 --- a/tests/routing/jev-decision.test.ts +++ b/tests/routing/jev-decision.test.ts @@ -359,6 +359,68 @@ describe("JEV decision parser", () => { }); }); + test("accepts Vercel decisions array-format answers and array probabilities", () => { + const payload = { + answers: [ + { + name: "route", + type: "choice", + value: "openai/gpt-6-astra:high", + probabilities: [ + { value: "openai/gpt-6-astra:medium", probability: 0.1 }, + { value: "openai/gpt-6-astra:high", probability: 0.7 }, + { value: "openai/gpt-5.6-sol:low", probability: 0.2 }, + ], + }, + ], + }; + + expect(parseJevDecision(payload, candidates)).toEqual({ + targetKey: "openai/gpt-6-astra", + effort: "high", + chosenProbability: 0.7, + }); + }); + + test("selects the unique route answer when array answers include earlier unrelated choices", () => { + const payload = { + answers: [ + { + name: "sentiment", + type: "choice", + value: "positive", + }, + { + name: "route", + type: "choice", + value: "openai/gpt-6-astra:high", + }, + ], + }; + + expect(parseJevDecision(payload, candidates)).toEqual({ + targetKey: "openai/gpt-6-astra", + effort: "high", + }); + }); + + test("rejects array answers with duplicate route choices or without a route answer", () => { + const duplicateRoute = { + answers: [ + { name: "route", type: "choice", value: "openai/gpt-6-astra:high" }, + { name: "route", type: "choice", value: "openai/gpt-5.6-sol:low" }, + ], + }; + expect(() => parseJevDecision(duplicateRoute, candidates)).toThrow("duplicate JEV route decision"); + + const unrelatedOnly = { + answers: [ + { name: "survey", type: "choice", value: "positive" }, + ], + }; + expect(() => parseJevDecision(unrelatedOnly, candidates)).toThrow("missing JEV route decision"); + }); + test("treats malformed confidence as absent without weakening the route choice", () => { expect(parseJevDecision({ answers: { route: { choice: "openai/gpt-5.6-sol:low", confidence: 2 } }, @@ -390,6 +452,24 @@ describe("JEV decision parser", () => { }), candidates)).toThrow(); expect(() => parseJevDecision(answer("openai/gpt-5.6-sol:low", complete), candidates)).toThrow(); expect(() => parseJevDecision({ answers: null }, candidates)).toThrow(); + expect(() => parseJevDecision({ + answers: [{ name: "route", value: "openai/gpt-6-astra:high", probabilities: [null] }], + }, candidates)).toThrow(); + expect(() => parseJevDecision({ + answers: [{ name: "route", value: "openai/gpt-6-astra:high", probabilities: [{ probability: 0.7 }] }], + }, candidates)).toThrow(); + expect(() => parseJevDecision({ + answers: [{ + name: "route", + value: "openai/gpt-6-astra:high", + probabilities: [ + { value: "openai/gpt-6-astra:medium", probability: 0.1 }, + { value: "openai/gpt-6-astra:high", probability: 0.7 }, + { value: "openai/gpt-5.6-sol:low", probability: 0.2 }, + { value: "openai/gpt-6-astra:high", probability: 0.7 }, + ], + }], + }, candidates)).toThrow(); }); }); @@ -951,6 +1031,49 @@ describe("JEV configurable decision provider", () => { }).questions.route.criteria)).toHaveLength(26); }); + test("Decisions endpoint permits more than 26 choices up to 255", async () => { + const calls: Call[] = []; + const post = recordingPost(calls, () => Response.json({ + answers: { route: { choice: "p/m-0:low" } }, + })); + const many: JevCandidate[] = Array.from({ length: 9 }, (_, index) => ({ + key: `p/m-${index}`, + provider: "p", + model: `m-${index}`, + reasoningEfforts: ["low", "medium", "high"], + modelProfile: `profile for m-${index}`, + })); + const manyFallback = { targetKey: many[0]!.key, effort: "low" as const }; + const vercelConfig: OcxConfig = { + providers: { + "vercel-decider": { + adapter: "jev-decision", + baseUrl: "https://ai-gateway.vercel.sh/v1/systemone", + defaultModel: "tev1:4b", + apiKey: "fixture-key", + }, + }, + }; + const decision = await resolveJevDecision({ + body: { ...decisionBody, input: [{ role: "user", content: "hello" }, { type: "function_call_output", output: "ok" }] }, + candidates: many, + fallback: manyFallback, + config: vercelConfig, + decisionProvider: "vercel-decider", + post, + }); + expect(decision.gate).toBe("apply"); + expect(decision.targetKey).toBe("p/m-0"); + expect(decision.effort).toBe("low"); + expect(calls).toHaveLength(1); + expect(calls[0]!.url).toBe("https://ai-gateway.vercel.sh/v1/decisions"); + const parsed = JSON.parse(String(calls[0]!.init.body)); + expect(parsed.questions[0].choices).toHaveLength(27); + expect(parsed.input).toContain("Task: hello"); + expect(parsed.input).toContain("Signals: tool_history"); + expect(parsed.questions[0].choices[0].description).toContain("profile for m-0"); + }); + test("a self-hosted row sends only its own key and never a TypeSafe credential", async () => { await withEnvKeys(async () => { const calls: Call[] = []; diff --git a/tests/routing/jev-service-exchange.test.ts b/tests/routing/jev-service-exchange.test.ts index 664eb54893f..6fae43299ae 100644 --- a/tests/routing/jev-service-exchange.test.ts +++ b/tests/routing/jev-service-exchange.test.ts @@ -1,3 +1,4 @@ +import { isDecisionsEndpoint, jevDecisionEndpointUrl } from "../../src/combos/jev-decision-contract"; import { describe, expect, test } from "bun:test"; import { parseJevDecision, resolveJevDecision, type JevCandidate } from "../../src/combos/jev"; import { @@ -494,6 +495,38 @@ describe("bounded JEV service exchange", () => { expect(route.gate).toBe("apply"); }); + test("rewrites ai-gateway.vercel.sh endpoint to /decisions and sends Decisions questions body", async () => { + expect(jevDecisionEndpointUrl("https://ai-gateway.vercel.sh/v1/systemone")).toBe("https://ai-gateway.vercel.sh/v1/decisions"); + expect(jevDecisionEndpointUrl("https://ai-gateway.vercel.sh/v1")).toBe("https://ai-gateway.vercel.sh/v1/decisions"); + expect(jevDecisionEndpointUrl("https://my-ai-gateway.vercel.sh/v1/systemone")).toBe("https://my-ai-gateway.vercel.sh/v1/systemone"); + expect(isDecisionsEndpoint("https://ai-gateway.vercel.sh/v1/decisions")).toBeTrue(); + expect(isDecisionsEndpoint("https://custom.vercel.sh/v1/decisions")).toBeFalse(); + + const { sent, direct, route } = await sendTo("https://ai-gateway.vercel.sh/v1/systemone"); + expect(sent.map(r => r.url)).toEqual([ + "https://ai-gateway.vercel.sh/v1/decisions", + "https://ai-gateway.vercel.sh/v1/decisions", + ]); + expect(direct).toEqual({ value: answer }); + expect(route.gate).toBe("apply"); + + const parsedRouteBody = JSON.parse(sent[1].body); + expect(parsedRouteBody.model).toBe("tev1:4b"); + expect(parsedRouteBody.input).toContain("Task: Choose a target."); + expect(Array.isArray(parsedRouteBody.questions)).toBeTrue(); + expect(parsedRouteBody.questions).toHaveLength(1); + const q = parsedRouteBody.questions[0]; + expect(q.name).toBe("route"); + expect(q.type).toBe("choice"); + expect(Array.isArray(q.choices)).toBeTrue(); + expect(q.choices.length).toBeGreaterThan(0); + for (const choice of q.choices) { + expect(typeof choice.value).toBe("string"); + expect(typeof choice.description).toBe("string"); + expect(choice.description.length).toBeGreaterThan(0); + } + }); + test("the request body does not depend on the endpoint path", async () => { const bodies = new Set(); for (const baseUrl of ["https://decider.example/v1/decisions/", "https://decider.example/v1/systemone/"]) { From 48b77eecc039576d576c8becc18ac46398cdc457 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 26 Oct 2026 20:32:51 +0900 Subject: [PATCH 082/101] fix(update): join the Windows npm tree before stage cleanup (#7751) * fix(update): join Windows npm tree before stage cleanup * test(update): synchronize npm timeout fixture readiness * fix(update): assign npm installer job atomically at creation --------- Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> Co-authored-by: lidge-jun --- bin/ocx.mjs | 15 +- .../docs/reference/platform-support.md | 4 + .../docs/troubleshooting/update-failed.md | 19 ++ scripts/test-layout/layout.json | 2 +- src/update/npm-install-job.ps1 | 173 +++++++++++++++++ src/update/npm-install.mjs | 70 +++++++ src/update/transactional-install.d.mts | 17 +- src/update/transactional-install.mjs | 40 +++- structure/ops/service-and-sidecars.md | 11 +- structure/runtime.md | 2 +- tests/cli/ocx-launcher-source.test.ts | 2 +- tests/fixtures/npm-timeout-tree.mjs | 25 +++ tests/fixtures/test-layout-expected.json | 2 +- .../update/update-npm-supervisor-exit.test.ts | 126 ++++++++++++ tests/update/update-npm-timeout.test.ts | 180 ++++++++++++++++++ tests/update/update-stop-first.test.ts | 2 +- 16 files changed, 664 insertions(+), 26 deletions(-) create mode 100644 src/update/npm-install-job.ps1 create mode 100644 src/update/npm-install.mjs create mode 100644 tests/fixtures/npm-timeout-tree.mjs create mode 100644 tests/update/update-npm-supervisor-exit.test.ts create mode 100644 tests/update/update-npm-timeout.test.ts diff --git a/bin/ocx.mjs b/bin/ocx.mjs index 6f3d29a138b..22c391d4b42 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -40,6 +40,7 @@ import { isRealBunBinary } from "../src/lib/bun-binary-validator.mjs"; import { scopeTranspilerCacheToRuntime } from "../src/lib/bun-transpiler-cache-scope.mjs"; import { findDesktopCli, findPathBun } from "../src/lib/bun-path-runtime.mjs"; import { npmInvocation } from "../src/update/npm-invocation.mjs"; +import { runNpmInstall } from "../src/update/npm-install.mjs"; import { pnpmInvocationForPath, resolvePnpmCommands } from "../src/update/pnpm-invocation.mjs"; import { detectInstallOwnershipFromPath } from "../src/update/install-detection.mjs"; import { @@ -56,7 +57,7 @@ import { runNpmCachePreflight, } from "../src/update/npm-cache-preflight.mjs"; import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from "../src/update/tray-update-plan.mjs"; -import { bootRestoreProbe, launcherUsableAfterNpmUpdate, transactionalNpmUpdate } from "../src/update/transactional-install.mjs"; +import { bootRestoreProbe, launcherUsableAfterNpmUpdate, transactionalNpmUpdate as transactionalNpmUpdateSync, transactionalNpmUpdateAsync } from "../src/update/transactional-install.mjs"; import { manualUpdateFailureGuidance, npmUpdateFailureGuidance } from "../src/update/update-failure-guidance.mjs"; import { captureCodexCliTransportEnv, @@ -213,7 +214,7 @@ function runningPnpmShimPath() { return resolve(invoked); } -function runPackageManagerSelfUpdate(manager) { +async function runPackageManagerSelfUpdate(manager) { const current = currentPackageVersion(); const tag = updateTag(current); let owner; @@ -850,7 +851,8 @@ function runPackageManagerSelfUpdate(manager) { try { if (manager === "npm") { const packageDir = resolve(here, ".."); - const tx = transactionalNpmUpdate({ + const transactionalNpmUpdate = process.platform === "win32" ? transactionalNpmUpdateAsync : transactionalNpmUpdateSync; + const tx = await transactionalNpmUpdate({ packageDir, pkgName: PKG, targetVersion: latest || undefined, @@ -858,6 +860,9 @@ function runPackageManagerSelfUpdate(manager) { cachePath: npmCachePath, runNpm: (args) => { const invocation = npmInvocation(args); + if (process.platform === "win32") return runNpmInstall(invocation, { + env: unprivilegedOwnershipMutationEnvironment(invocation?.options?.env ?? process.env), + }); if (!invocation) return { status: 1 }; return spawnSync(invocation.file, invocation.args, { ...invocation.options, @@ -1135,8 +1140,8 @@ if (process.argv[2] === "update" && installMethod === "mise") { } if (process.argv[2] === "update" && isNodeModulesInstall() && !isBunGlobalInstall()) { - if (installMethod === "npm") runNpmSelfUpdate(); - if (installMethod === "pnpm") runPnpmSelfUpdate(); + if (installMethod === "npm") await runNpmSelfUpdate(); + if (installMethod === "pnpm") await runPnpmSelfUpdate(); } // #1849 boot probe: a prior update that lost power (or double-faulted) mid-swap leaves a diff --git a/docs-site/src/content/docs/reference/platform-support.md b/docs-site/src/content/docs/reference/platform-support.md index edbb09b4d43..a564c8d0826 100644 --- a/docs-site/src/content/docs/reference/platform-support.md +++ b/docs-site/src/content/docs/reference/platform-support.md @@ -86,6 +86,10 @@ destinations keep their existing tool-selection behavior. ## Windows notes +The Node launcher's npm self-update requires Windows 10 or later with Job List +process-creation attributes. If atomic process supervision is unavailable, it +reports `EINSTALLJOIN` and does not start npm; update Windows before retrying. + The Windows service can run under Task Scheduler or as a native WinSW service, and those are mutually exclusive. `ocx service repair` refuses to proceed when it finds state for both, because guessing which one you meant is how a machine diff --git a/docs-site/src/content/docs/troubleshooting/update-failed.md b/docs-site/src/content/docs/troubleshooting/update-failed.md index df5b916612c..678eb5b03d7 100644 --- a/docs-site/src/content/docs/troubleshooting/update-failed.md +++ b/docs-site/src/content/docs/troubleshooting/update-failed.md @@ -34,6 +34,25 @@ paths. Running `ocx update` in a terminal shows the step that failed and the nex ## Finish the update +### If npm staging times out + +On Windows, the npm-backed `ocx update` staging install has a three-minute budget. A timeout reports +`ETIMEDOUT` with that budget. On Windows, the updater terminates the installer's entire +process tree and confirms its exit before removing the stage and recovering the previous +service. It does not continue extracting into a deleted stage after recovery starts. + +If installer exit cannot be confirmed, the update fails and keeps the staging folder and +its ownership marker. Confirm that no installer or OpenCodex process runs from that folder +before removing it. A retained folder does not prevent another update attempt. + +Check your registry connection if the install repeatedly exceeds the budget, then retry +`ocx update`. Increasing the budget is not needed to clean up a timed-out install safely. + +This process-tree exit guarantee applies to Windows npm updates. POSIX retains its existing +timeout handling; complete-tree timeout cleanup remains open there. + +### Retry or reinstall + 1. Close anything that may still run OpenCodex files: the Codex app, terminals that started `ocx` or `bunx`, and the OpenCodex tray. A running `bun.exe` or `bunx.exe` keeps its files locked on Windows. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 4a54711c181..29e99444034 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1842,7 +1842,7 @@ "update-desktop-badge.test.ts": "update", "update-desktop-owner.test.ts": "update", "update-supervised-recovery.test.ts": "update", "update-job.test.ts": "update", "update-recovery-host-free.test.ts": "update", "update-job-supervised-restart.test.ts": "update", "update-standalone.test.ts": "update", "update-worker-launch.test.ts": "update", "update-notify.test.ts": "update", "update-npm-cache-preflight.test.ts": "update", - "update-npm-invocation.test.ts": "update", "update-mise-launcher-target.test.ts": "update", + "update-npm-invocation.test.ts": "update", "update-npm-timeout.test.ts": "update", "update-npm-supervisor-exit.test.ts": "update", "update-mise-launcher-target.test.ts": "update", "update-mise.test.ts": "update", "update-mise-node-runtime.test.ts": "update", "update-pnpm.test.ts": "update", "update-refresh.test.ts": "update", diff --git a/src/update/npm-install-job.ps1 b/src/update/npm-install-job.ps1 new file mode 100644 index 00000000000..95ce010b25a --- /dev/null +++ b/src/update/npm-install-job.ps1 @@ -0,0 +1,173 @@ +# ASCII-only Windows PowerShell 5.1 supervisor. Arguments arrive as JSON on stdin. +# The child is born in our private job; no installer can outlive its supervisor. +$ErrorActionPreference = 'Stop' +$utf8 = New-Object System.Text.UTF8Encoding($false, $true) +$reader = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), $utf8, $false) +$request = $reader.ReadToEnd() | ConvertFrom-Json +Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; + +public static class NpmInstallJob { + [StructLayout(LayoutKind.Sequential)] struct Startup { + public int cb; public IntPtr reserved, desktop, title; + public int x, y, width, height, charsX, charsY, fill, flags; + public short show, reservedSize; public IntPtr reservedData, stdin, stdout, stderr; + } + [StructLayout(LayoutKind.Sequential)] struct ProcessInfo { + public IntPtr process, thread; public uint pid, tid; + } + [StructLayout(LayoutKind.Sequential)] struct StartupEx { + public Startup startup; public IntPtr attributes; + } + [StructLayout(LayoutKind.Sequential)] struct Limits { + public long processTime, jobTime; public uint flags; + public UIntPtr minWorkingSet, maxWorkingSet; public uint activeLimit; + public UIntPtr affinity; public uint priority, scheduling; + } + [StructLayout(LayoutKind.Sequential)] struct ExtendedLimits { + public Limits basic; + public ulong readOps, writeOps, otherOps, readBytes, writeBytes, otherBytes; + public UIntPtr processMemory, jobMemory, peakProcessMemory, peakJobMemory; + } + [StructLayout(LayoutKind.Sequential)] struct Accounting { + public long userTime, kernelTime, periodUserTime, periodKernelTime; + public uint faults, total, active, terminated; + } + public sealed class Result { + public object status; public bool processTreeExited; public object error; + } + public sealed class InstallError { + public string code, message; + public InstallError(string c, string m) { code = c; message = m; } + } + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] + static extern IntPtr CreateJobObject(IntPtr attributes, string name); + [DllImport("kernel32.dll", SetLastError=true)] + static extern bool SetInformationJobObject(IntPtr job, int info, ref ExtendedLimits limits, uint size); + [DllImport("kernel32.dll", SetLastError=true)] + static extern bool QueryInformationJobObject(IntPtr job, int info, out Accounting data, uint size, IntPtr returned); + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] + static extern bool CreateProcess(string file, StringBuilder args, IntPtr pa, IntPtr ta, bool inherit, uint flags, + IntPtr environment, string cwd, ref StartupEx startup, out ProcessInfo process); + [DllImport("kernel32.dll", SetLastError=true)] + static extern bool InitializeProcThreadAttributeList(IntPtr list, int count, uint flags, ref IntPtr size); + [DllImport("kernel32.dll", SetLastError=true)] + static extern bool UpdateProcThreadAttribute(IntPtr list, uint flags, IntPtr attribute, IntPtr value, + IntPtr size, IntPtr previous, IntPtr returned); + [DllImport("kernel32.dll")] static extern void DeleteProcThreadAttributeList(IntPtr list); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool TerminateJobObject(IntPtr job, uint code); + [DllImport("kernel32.dll", SetLastError=true)] static extern uint ResumeThread(IntPtr thread); + [DllImport("kernel32.dll")] static extern uint WaitForSingleObject(IntPtr handle, uint ms); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool GetExitCodeProcess(IntPtr process, out uint code); + [DllImport("kernel32.dll")] static extern IntPtr GetStdHandle(int number); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool SetHandleInformation(IntPtr handle, uint mask, uint flags); + [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr handle); + static void Require(bool ok) { if (!ok) throw new Win32Exception(Marshal.GetLastWin32Error()); } + static void RequireAttribute(bool ok) { + if (!ok) { + int code = Marshal.GetLastWin32Error(); + throw new Win32Exception(code, + "Atomic npm supervision requires Windows 10 or later with Job List and handle-list process attributes; installer was not started: " + + new Win32Exception(code).Message); + } + } + static uint Active(IntPtr job) { + Accounting data; + Require(QueryInformationJobObject(job, 1, out data, (uint)Marshal.SizeOf(typeof(Accounting)), IntPtr.Zero)); + return data.active; + } + public static Result Run(string file, string args, string cwd, int timeoutMs, int joinMs) { + IntPtr job = IntPtr.Zero, attributes = IntPtr.Zero, jobList = IntPtr.Zero, handles = IntPtr.Zero; + ProcessInfo process = new ProcessInfo(); + bool started = false, attributesInitialized = false, timedOut = false; + Result result = new Result { status = null, processTreeExited = true }; + try { + job = CreateJobObject(IntPtr.Zero, null); Require(job != IntPtr.Zero); + ExtendedLimits limits = new ExtendedLimits(); + limits.basic.flags = 0x2000; // JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; no breakaway. + Require(SetInformationJobObject(job, 9, ref limits, (uint)Marshal.SizeOf(typeof(ExtendedLimits)))); + StartupEx startup = new StartupEx(); startup.startup.cb = Marshal.SizeOf(typeof(StartupEx)); startup.startup.flags = 0x100; + startup.startup.stdin = GetStdHandle(-10); + // Keep stdout exclusively for the one supervisor result. npm logs inherit stderr. + startup.startup.stdout = startup.startup.stderr = GetStdHandle(-12); + Require(SetHandleInformation(startup.startup.stdin, 1, 1)); + Require(SetHandleInformation(startup.startup.stderr, 1, 1)); + Require(SetHandleInformation(GetStdHandle(-11), 1, 0)); // Result pipe is supervisor-only. + IntPtr size = IntPtr.Zero; + InitializeProcThreadAttributeList(IntPtr.Zero, 2, 0, ref size); + RequireAttribute(size != IntPtr.Zero); + attributes = Marshal.AllocHGlobal(size); + RequireAttribute(InitializeProcThreadAttributeList(attributes, 2, 0, ref size)); + attributesInitialized = true; startup.attributes = attributes; + jobList = Marshal.AllocHGlobal(IntPtr.Size); Marshal.WriteIntPtr(jobList, job); + // PROC_THREAD_ATTRIBUTE_JOB_LIST: membership is atomic with creation (Windows 10+). + RequireAttribute(UpdateProcThreadAttribute(attributes, 0, new IntPtr(0x0002000D), jobList, + new IntPtr(IntPtr.Size), IntPtr.Zero, IntPtr.Zero)); + handles = Marshal.AllocHGlobal(2 * IntPtr.Size); + Marshal.WriteIntPtr(handles, 0, startup.startup.stdin); + Marshal.WriteIntPtr(handles, IntPtr.Size, startup.startup.stderr); + // PROC_THREAD_ATTRIBUTE_HANDLE_LIST: never inherit the Job or result pipe. + RequireAttribute(UpdateProcThreadAttribute(attributes, 0, new IntPtr(0x00020002), handles, + new IntPtr(2 * IntPtr.Size), IntPtr.Zero, IntPtr.Zero)); + // Fail closed on unsupported attributes/creation: never retry without the Job list. + RequireAttribute(CreateProcess(file, new StringBuilder(args), IntPtr.Zero, IntPtr.Zero, true, + 0x08080004, IntPtr.Zero, cwd, ref startup, out process)); // NO_WINDOW | EXTENDED_STARTUPINFO_PRESENT | SUSPENDED + started = true; result.processTreeExited = false; + // Keep suspension until local exit-proof/error handling is armed. + Require(ResumeThread(process.thread) != UInt32.MaxValue); + Stopwatch clock = Stopwatch.StartNew(); + while (Active(job) != 0 && clock.ElapsedMilliseconds < timeoutMs) Thread.Sleep(20); + if (Active(job) != 0) { + timedOut = true; + result.error = new InstallError("ETIMEDOUT", "npm install exceeded " + timeoutMs + " ms"); + Require(TerminateJobObject(job, 1)); + clock.Restart(); + while (Active(job) != 0 && clock.ElapsedMilliseconds < joinMs) Thread.Sleep(10); + } else clock.Restart(); + uint remainingJoinMs = (uint)Math.Max(0L, joinMs - clock.ElapsedMilliseconds); + result.processTreeExited = Active(job) == 0 && WaitForSingleObject(process.process, remainingJoinMs) == 0; + if (result.processTreeExited && result.error == null) { + uint code; Require(GetExitCodeProcess(process.process, out code)); result.status = (long)code; + } else if (!result.processTreeExited) { + result.error = new InstallError(timedOut ? "ETIMEDOUT" : "EINSTALLJOIN", + (timedOut ? "npm install exceeded " + timeoutMs + " ms; " : "") + "process-tree exit could not be confirmed; stage retained"); + } + } catch (Exception error) { + if (started) { + TerminateJobObject(job, 1); + Stopwatch clock = Stopwatch.StartNew(); + while (clock.ElapsedMilliseconds < joinMs) { + try { + if (WaitForSingleObject(process.process, 0) == 0 && Active(job) == 0) { + result.processTreeExited = true; break; + } + } catch { break; } + Thread.Sleep(10); + } + } + InstallError timeout = result.error as InstallError; + result.error = new InstallError(timeout == null ? "EINSTALLJOIN" : timeout.code, + (timeout == null ? "" : timeout.message + "; ") + "npm supervision failed: " + error.Message); + } finally { + if (attributesInitialized) DeleteProcThreadAttributeList(attributes); + if (attributes != IntPtr.Zero) Marshal.FreeHGlobal(attributes); + if (handles != IntPtr.Zero) Marshal.FreeHGlobal(handles); + if (jobList != IntPtr.Zero) Marshal.FreeHGlobal(jobList); + if (process.thread != IntPtr.Zero) CloseHandle(process.thread); + if (process.process != IntPtr.Zero) CloseHandle(process.process); + if (job != IntPtr.Zero) CloseHandle(job); + } + return result; + } +} +'@ +$result = [NpmInstallJob]::Run([string]$request.file, [string]$request.commandLine, [string]$request.cwd, + [int]$request.timeoutMs, [int]$request.joinMs) | ConvertTo-Json -Compress -Depth 4 +$writer = New-Object System.IO.StreamWriter([Console]::OpenStandardOutput(), $utf8) +$writer.Write($result) +$writer.Flush() diff --git a/src/update/npm-install.mjs b/src/update/npm-install.mjs new file mode 100644 index 00000000000..28424dbf5c5 --- /dev/null +++ b/src/update/npm-install.mjs @@ -0,0 +1,70 @@ +// Windows Node-launcher compatibility code. The install owns its entire process tree until +// exit is proved; a timeout signal alone never authorizes stage removal. +import { spawn } from "node:child_process"; +import { dirname, join, win32 } from "node:path"; +import { fileURLToPath } from "node:url"; + +const JOIN_MS = 10000; +const failure = message => ({ status: null, processTreeExited: false, error: { code: "EINSTALLJOIN", message } }); + +function quoteWindowsArg(value) { + return '"' + value.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, "$1$1") + '"'; +} + +export async function runNpmInstall(invocation, { env = process.env, timeoutMs = 180000 } = {}) { + if (!invocation) return { status: 1, processTreeExited: true, error: { code: "ENOENT", message: "npm invocation unavailable" } }; + if (process.platform === "win32") { + // Resolve from the same absolute system directory as the hardened cmd hop. + // Never search PATH for PowerShell, and never interpolate the JSON as code. + const roots = new Set(Object.entries(env).filter(([key, value]) => key.toLowerCase() === "systemroot" && value !== undefined).map(([, value]) => value)); + const root = env.SystemRoot ?? (roots.size === 1 ? [...roots][0] : undefined); + if (!root || !win32.isAbsolute(root)) return failure("absolute SystemRoot is required for npm process supervision"); + const supervisor = win32.join(root, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"); + const helper = join(dirname(fileURLToPath(import.meta.url)), "npm-install-job.ps1"); + return new Promise(resolve => { + const child = spawn(supervisor, ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", helper], { + env, windowsHide: true, stdio: ["pipe", "pipe", "pipe"], + }); + let output = ""; + let invalid = false; + let spawned = false; + let completed = false; + const complete = result => { + if (completed) return; + completed = true; + clearTimeout(watchdog); + child.stdin.destroy(); child.stdout.destroy(); child.stderr.destroy(); + resolve(result); + }; + const watchdog = setTimeout(() => { + child.kill(); + complete(failure(`npm supervisor exceeded ${timeoutMs} ms install budget and exit-confirmation budget; stage retained`)); + }, timeoutMs + JOIN_MS + 30000); + child.on("spawn", () => { spawned = true; }); + child.stderr.on("data", chunk => { if (!completed) process.stderr.write(chunk); }); + child.stdout.setEncoding("utf8"); + child.stdout.on("data", chunk => { + if (output.length + chunk.length > 16384) { invalid = true; child.kill(); complete(failure("invalid npm supervisor output; stage retained")); } + else output += chunk.toString("utf8"); + }); + child.on("error", error => { + complete({ status: null, processTreeExited: !spawned, error: { code: error.code, message: error.message } }); + }); + child.on("close", status => { + clearTimeout(watchdog); + try { + const result = JSON.parse(output); + if (invalid || status !== 0 || typeof result.processTreeExited !== "boolean" + || !(result.status === null || Number.isInteger(result.status))) throw new Error("invalid supervisor result"); + complete(result); + } catch { complete(failure("npm supervisor did not confirm process-tree exit; stage retained")); } + }); + child.stdin.on("error", () => { /* close/result decides whether the helper ran */ }); + const args = invocation.options?.windowsVerbatimArguments ? invocation.args.join(" ") : invocation.args.map(quoteWindowsArg).join(" "); + child.stdin.end(JSON.stringify({ file: invocation.file, commandLine: quoteWindowsArg(invocation.file) + " " + args, + cwd: process.cwd(), timeoutMs, joinMs: JOIN_MS })); + }); + } + + return failure("the npm Job Object supervisor is Windows-only"); +} diff --git a/src/update/transactional-install.d.mts b/src/update/transactional-install.d.mts index 15573c780b2..26ce25e13e2 100644 --- a/src/update/transactional-install.d.mts +++ b/src/update/transactional-install.d.mts @@ -25,20 +25,31 @@ export function sweepUpdateLeftovers(args: { notOwned: string[]; }; export function launcherUsableAfterNpmUpdate(tx: { ok: boolean; phase: string; rolledBack?: boolean } | null | undefined): boolean; -export function transactionalNpmUpdate(args: { +export type NpmInstallResult = { + status: number | null; + error?: { code?: string; message?: string }; + signal?: string | null; + processTreeExited?: boolean; +}; +type NpmUpdateOptions = { packageDir: string; pkgName: string; targetVersion?: string; tag: string; - runNpm: (args: string[]) => { status: number | null }; + runNpm: (args: string[]) => NpmInstallResult; /** npm cache root the pre-flight resolved and checked; pinned with `--cache` for staging. */ cachePath?: string; log?: (line: string) => void; deps?: UpdateFsDeps; -}): { +}; +type NpmUpdateResult = { ok: boolean; phase: "stage" | "verify" | "swap-backup" | "swap-live" | "post-verify" | "double-fault" | "done"; error?: string; rolledBack?: boolean; backup?: string; }; +export function transactionalNpmUpdate(args: NpmUpdateOptions): NpmUpdateResult; +export function transactionalNpmUpdateAsync(args: Omit & { + runNpm: (args: string[]) => NpmInstallResult | Promise; +}): Promise; diff --git a/src/update/transactional-install.mjs b/src/update/transactional-install.mjs index 07d1dcc6ad6..f72e42f9aad 100644 --- a/src/update/transactional-install.mjs +++ b/src/update/transactional-install.mjs @@ -384,12 +384,11 @@ export function bootRestoreProbe(packageDir, deps = {}) { * Run the transactional update. `runNpm(args, opts)` is injected so the caller keeps * its hardened npm resolution (npm-invocation.mjs) and logging. */ -export function transactionalNpmUpdate({ +function* npmUpdateTransaction({ packageDir, pkgName, targetVersion, tag, - runNpm, cachePath, log = () => {}, deps = {}, @@ -437,13 +436,18 @@ export function transactionalNpmUpdate({ // operator's global npmrc would be dropped and npm would fall back to its default root. Pin // the cache the pre-flight resolved and checked, so staging uses that exact root (#6288). const cacheArgs = typeof cachePath === "string" && cachePath.length > 0 ? ["--cache", cachePath] : []; - const install = runNpm([ + const install = yield [ "install", "-g", "--prefix", stageRoot, ...cacheArgs, "--allow-scripts=bun", "--no-audit", "--no-fund", spec, - ]); - if (install.status !== 0) { - discardStage(); - return { ok: false, phase: "stage", error: "npm staging install failed (" + (install.status ?? "?") + ")" }; + ]; + if (install.status !== 0 || install.error || install.processTreeExited === false) { + if (install.processTreeExited === false) { + log("Left this update's staging directory in place: process-tree exit was not confirmed; stop the installer before removing " + basename(stageRoot)); + } else discardStage(); + const detail = install.error + ? [install.error.code, install.error.message].filter(Boolean).join(": ") + : install.signal ?? install.status ?? "?"; + return { ok: false, phase: "stage", error: "npm staging install failed (" + detail + ")" }; } const stagedPackage = stagedCandidatesIn(stageRoot).find(dir => existsSync(join(dir, "package.json"))); if (!stagedPackage) { @@ -524,3 +528,25 @@ export function transactionalNpmUpdate({ discardStage(); return { ok: true, phase: "done", backup: backupPackage }; } + +/** Keep synchronous callers while the Node launcher awaits the install/exit barrier. */ +export function transactionalNpmUpdate(options) { + const transaction = npmUpdateTransaction(options); + const step = transaction.next(); + if (step.done) return step.value; + // Preserve the legacy status-only transaction on POSIX and for existing callers. + const install = options.runNpm(step.value); + return transaction.next({ status: install.status }).value; +} + +export async function transactionalNpmUpdateAsync(options) { + const transaction = npmUpdateTransaction(options); + const step = transaction.next(); + if (step.done) return step.value; + let install; + try { install = await options.runNpm(step.value); } + catch (error) { install = { status: null, error, processTreeExited: false }; } + if (install.processTreeExited !== true) install = { ...install, status: null, processTreeExited: false, + error: install.error ?? { code: "EINSTALLJOIN", message: "npm process-tree exit was not confirmed" } }; + return transaction.next(install).value; +} diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 309caeb0697..e0976d7eb26 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -464,12 +464,11 @@ home aliases without trusting a redirected persist subtree. Unreadable paths fai Other Scoop apps, version-directory entries (`NO_JUNCTION`), custom home-root Scoop installs, arbitrary descendants and cwd inside the resolved Node installation are not admitted. -The npm transaction creates each staging directory exclusively and may clean that fresh path -while the creating process still owns it. On POSIX it also creates the stage's `lib` directory, -because npm's strict script policy plans the global tree before it creates the prefix layout -(#5760). A later update only reports staging leftovers. It does not recursively delete them -from a marker: the marker is not an authorization secret, and a neighbouring writer could -replace a previously checked pathname with a link before traversal. +The Windows npm staging runner (`src/update/npm-install.mjs`) keeps a 180-second install budget and a separate 10-second join budget. Before cleanup or verification, it requires proof that the full installer tree exited, including when npm returns status zero. +On Windows, `src/update/npm-install-job.ps1` creates the root atomically inside a private kill-on-close Job Object using `STARTUPINFOEX` and `PROC_THREAD_ATTRIBUTE_JOB_LIST`. It stays suspended until local exit-proof handling is armed. `PROC_THREAD_ATTRIBUTE_HANDLE_LIST` admits only stdin and npm's stderr, excluding the Job and supervisor result pipe; attribute lists and value buffers are freed. Windows 10+ Job List support is required: attribute setup or extended creation failure returns `EINSTALLJOIN` with a clear prerequisite and native error, without starting an unsupervised installer or retrying a legacy creation path. Timeout terminates the job, checks zero active members and waits for the root handle. The updater and recovery stay outside this job; a watchdog bounds supervisor startup/protocol failure. +Missing exit proof retains the complete stage and marker. npm output drains before a successful join returns; failed supervision stops forwarding it. Windows timeout diagnostics retain `ETIMEDOUT` and the budget; stage failures retain owner-aware recovery through the untouched launcher. POSIX keeps the legacy synchronous install and status-only transaction; complete-tree timeout cleanup remains open there because process groups do not contain detached descendants. +The npm transaction creates each staging directory exclusively and may clean that fresh path while the creating process still owns it. On POSIX it also creates the stage's `lib` directory, because npm's strict script policy plans the global tree before creating the prefix layout (#5760). +A later update only reports staging leftovers. It does not recursively delete them from a marker: the marker is not an authorization secret, and a neighbouring writer could replace a previously checked pathname with a link before traversal. Before any tray or proxy stop, `src/update/npm-cache-preflight.mjs` checks npm's cache on every platform (#6288). The cache root, or the nearest existing folder npm would create it under, must diff --git a/structure/runtime.md b/structure/runtime.md index 16f14c55e95..4ab0293778f 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -595,6 +595,6 @@ remains a reported recovery requirement. Dashboard restart delegates the lease t The registration is never deleted; `ocx service install` releases the marker only after the registration succeeds. Busy lease diagnostics name the recorded PID, current liveness, optional executable (omitted by default on Windows), and lease age. The process identity is unverified because a PID may be reused. -Bun updater lease and recovery behavior follows the [update transaction contract](ops/service-and-sidecars.md#bun-updater-ownership-transaction). +Bun updater lease and recovery behavior follows the [update transaction contract](ops/service-and-sidecars.md#bun-updater-ownership-transaction). On Windows, the Node npm self-update awaits `src/update/transactional-install.mjs`'s full installer-tree exit barrier before stage cleanup or verification: a staging-only Job Object owns the tree atomically from root creation, missing exit evidence retains the stage and marker, and stage failures preserve the untouched launcher for owner-aware recovery. Windows 10+ Job List attributes are required; unavailable atomic supervision fails before installer creation. POSIX retains its synchronous install path. Companion timeline and filtered totals follow the [companion usage contract](companion.md). [Ongoing priority failback](providers/openai-accounts.md#ongoing-priority-failback) reuses request-triggered quota priming and captured-account dispatch; it adds no periodic worker or mid-request account switch. The serving-install census and bounded foreground delegation in `src/config/serving-runtimes.ts` follow [service command selection](ops/service-and-sidecars.md#background-service-command-selection). The live main-account policy projection is fetched from the attested account-list management API. `src/cli/status-oauth.ts` renders its state and effective thresholds with an optional outside-usage advisory; `src/cli/status.ts` includes the same whitelisted projection in `ocx status --json`. No policy state is inferred from the CLI process's local quota cache when the live read is unavailable. The experimental macOS `ocx chatgpt` launcher, restore and status commands follow the [ChatGPT Desktop contract](clients/chatgpt-desktop.md); the internal stdout filter remains hidden from public capability discovery. Generic OAuth conversation ownership follows [the generic account-pool contract](providers/generic-account-pool.md#conversation-affinity). diff --git a/tests/cli/ocx-launcher-source.test.ts b/tests/cli/ocx-launcher-source.test.ts index c6eb5840d42..972ad5b25ac 100644 --- a/tests/cli/ocx-launcher-source.test.ts +++ b/tests/cli/ocx-launcher-source.test.ts @@ -212,7 +212,7 @@ test("Desktop supervision is freshly checked before Node stop and both package-m const initial = source.indexOf("supervision: observeSupervision()", source.indexOf("const initialOwnership")); const gate = source.indexOf("const preStopPlan = planUpdateRuntimeHandling({"); const stop = source.indexOf('const stopRes = spawnSync(process.execPath, [launcher, "stop"]'); - const npm = source.indexOf("const tx = transactionalNpmUpdate({"); + const npm = source.indexOf("const tx = await transactionalNpmUpdate({"); const pnpm = source.indexOf("const update = runPnpmGlobalUpdate({"); expect(initial).toBeGreaterThan(0); expect(gate).toBeGreaterThan(initial); expect(stop).toBeGreaterThan(gate); expect(npm).toBeGreaterThan(stop); expect(pnpm).toBeGreaterThan(stop); diff --git a/tests/fixtures/npm-timeout-tree.mjs b/tests/fixtures/npm-timeout-tree.mjs new file mode 100644 index 00000000000..fa40cfe984c --- /dev/null +++ b/tests/fixtures/npm-timeout-tree.mjs @@ -0,0 +1,25 @@ +import { spawn } from "node:child_process"; +import { openSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +const [mode, stage, receipt] = process.argv.slice(2); +if (mode === "success") process.exit(0); +if (mode === "failure") process.exit(23); +if (mode === "leaf") { + // A cwd in the stage makes premature Windows removal fail, even if a file + // descriptor happens to permit FILE_SHARE_DELETE in the selected runtime. + process.chdir(stage); + openSync(join(stage, "held-file"), "w"); + process.send?.("ready"); + setInterval(() => {}, 1000); +} else { + const child = spawn(process.execPath, [process.argv[1], "leaf", stage, receipt], { + detached: true, + stdio: ["ignore", "ignore", "ignore", "ipc"], + }); + child.once("message", () => { + writeFileSync(receipt, JSON.stringify({ pids: [process.ppid, process.pid, child.pid] })); + if (mode === "orphan") process.exit(0); + }); + setInterval(() => {}, 1000); +} diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index e70d51a53db..385d2a59d16 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1274,7 +1274,7 @@ "update-job.test.ts": "update", "update-recovery-host-free.test.ts": "update", "update-job-supervised-restart.test.ts": "update", "update-standalone.test.ts": "update", "update-worker-launch.test.ts": "update", "update-notify.test.ts": "update", - "update-npm-cache-preflight.test.ts": "update", "update-npm-invocation.test.ts": "update", + "update-npm-cache-preflight.test.ts": "update", "update-npm-invocation.test.ts": "update", "update-npm-timeout.test.ts": "update", "update-npm-supervisor-exit.test.ts": "update", "update-mise-launcher-target.test.ts": "update", "update-mise.test.ts": "update", "update-mise-node-runtime.test.ts": "update", "update-pnpm.test.ts": "update", "update-refresh.test.ts": "update", "update-restart-lease.test.ts": "update", diff --git a/tests/update/update-npm-supervisor-exit.test.ts b/tests/update/update-npm-supervisor-exit.test.ts new file mode 100644 index 00000000000..812c64a6950 --- /dev/null +++ b/tests/update/update-npm-supervisor-exit.test.ts @@ -0,0 +1,126 @@ +import { expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { transactionalNpmUpdateAsync, UPDATE_OWNER_MARKER } from "../../src/update/transactional-install.mjs"; +import type { runNpmInstall } from "../../src/update/npm-install.mjs"; +import { fixturePath, repoPath } from "../helpers/repo-root"; + +const alive = (pid: number) => { + try { process.kill(pid, 0); return true; } + catch (error) { return (error as NodeJS.ErrnoException).code !== "ESRCH"; } +}; +async function until(predicate: () => boolean, budget = 2000) { + const deadline = Date.now() + budget; + while (!predicate() && Date.now() < deadline) await Bun.sleep(10); + return predicate(); +} + +async function runner(root: string, transform: (source: string) => string): Promise { + // Private copies expose precise crash/API-failure points without production switches. + const helper = join(root, "supervisor.ps1"); + writeFileSync(helper, transform(readFileSync(repoPath("src/update/npm-install-job.ps1"), "utf8")), "utf8"); + const source = readFileSync(repoPath("src/update/npm-install.mjs"), "utf8"); + const anchor = 'const helper = join(dirname(fileURLToPath(import.meta.url)), "npm-install-job.ps1");'; + expect(source.split(anchor)).toHaveLength(2); + return (await import("data:text/javascript;base64," + Buffer.from(source.replace(anchor, `const helper = ${JSON.stringify(helper)};`)).toString("base64"))).runNpmInstall; +} + +test.skipIf(process.platform !== "win32").each(["created", "tree"])("abrupt supervisor exit at %s kills installer and retains unproven stage", async point => { + const root = mkdtempSync(join(tmpdir(), "ocx-supervisor-exit-")); + const packageDir = join(root, "opencodex"); + const receipt = join(root, "created.json"); + const leafReceipt = join(root, "leaf.json"); + mkdirSync(packageDir); + writeFileSync(join(packageDir, "package.json"), '{"version":"1.0.0"}', "utf8"); + let stage = ""; + let pids: number[] = []; + let supervisor = 0; + let removals = 0; + let pending: ReturnType | undefined; + try { + const install = await runner(root, source => { + const anchor = point === "created" ? "started = true; result.processTreeExited = false;" : "Require(ResumeThread(process.thread) != UInt32.MaxValue);"; + expect(source.split(anchor)).toHaveLength(2); + const receipt64 = Buffer.from(receipt, "utf16le").toString("base64"); + return source.replace(anchor, `${anchor} + System.IO.File.WriteAllText(System.Text.Encoding.Unicode.GetString(Convert.FromBase64String("${receipt64}")), + "{\\"supervisor\\":" + Process.GetCurrentProcess().Id + ",\\"root\\":" + process.pid + "}"); + Thread.Sleep(Timeout.Infinite);`); + }); + pending = transactionalNpmUpdateAsync({ packageDir, pkgName: "@fixture/opencodex", tag: "latest", + runNpm: (args: string[]) => { + stage = args[args.indexOf("--prefix") + 1]!; + return install({ file: Bun.which("node")!, args: [fixturePath("npm-timeout-tree.mjs"), "tree", stage, leafReceipt], options: {} }); + }, deps: { rm: () => { removals++; } }, + }); + expect(await until(() => existsSync(receipt), 10000), "CreateProcess handshake").toBe(true); + const created = JSON.parse(readFileSync(receipt, "utf8")); + supervisor = created.supervisor; + pids = [created.root]; + if (point === "tree") { + expect(await until(() => existsSync(leafReceipt), 10000), "installer leaf handshake").toBe(true); + pids = [...new Set([...pids, ...JSON.parse(readFileSync(leafReceipt, "utf8")).pids])] as number[]; + expect(pids.length).toBeGreaterThanOrEqual(3); + } + expect(pids.every(alive)).toBe(true); + // Hold process handles before the kill and wait for kernel exit signals. + // Terminate ONLY the supervisor: taskkill /T would mask a Job membership bug. + const killer = join(root, "kill-supervisor.ps1"); + writeFileSync(killer, `param([int]$Supervisor, [string]$Pids) +$ErrorActionPreference = 'Stop' +$watched = @($Pids.Split(',') | ForEach-Object { [Diagnostics.Process]::GetProcessById([int]$_) }) +foreach ($entry in $watched) { $null = $entry.Handle } +$owner = [Diagnostics.Process]::GetProcessById($Supervisor) +$owner.Kill() +$owner.WaitForExit() +$exited = $true +foreach ($entry in $watched) { if (!$entry.WaitForExit(2000)) { $exited = $false }; $entry.Dispose() } +$owner.Dispose() +if ($exited) { [Console]::Write('exited') } else { [Console]::Write('alive') } +`, "utf8"); + const killed = spawnSync(join(process.env.SystemRoot!, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"), + ["-NoProfile", "-NonInteractive", "-File", killer, "-Supervisor", String(supervisor), "-Pids", pids.join(",")], + { windowsHide: true, encoding: "utf8", timeout: 10000 }); + expect(killed.status, killed.stderr).toBe(0); + expect(existsSync(join(stage, UPDATE_OWNER_MARKER))).toBe(true); + expect(removals).toBe(0); + expect(killed.stdout).toBe("exited"); + expect(pids.filter(alive), "installer survived supervisor termination").toEqual([]); + const result = await pending; + expect(result.phase).toBe("stage"); + expect(result.error).toContain("EINSTALLJOIN"); + expect(existsSync(join(stage, UPDATE_OWNER_MARKER))).toBe(true); + expect(removals).toBe(0); + } finally { + if (supervisor && alive(supervisor)) process.kill(supervisor); + if (existsSync(leafReceipt)) pids.push(...JSON.parse(readFileSync(leafReceipt, "utf8")).pids); + for (const pid of new Set(pids)) { + if (alive(pid)) spawnSync(join(process.env.SystemRoot!, "System32", "taskkill.exe"), ["/PID", String(pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" }); + } + await pending; + rmSync(root, { recursive: true, force: true }); + } +}, 20000); + +test.skipIf(process.platform !== "win32").each(["0x0002000D", "0x00020002"])("unsupported process attribute %s fails closed before installer creation", async attribute => { + const root = mkdtempSync(join(tmpdir(), "ocx-job-unavailable-")); + const launched = join(root, "installer-ran"); + try { + const install = await runner(root, source => { + const anchor = `new IntPtr(${attribute})`; + expect(source.split(anchor)).toHaveLength(2); + // Exercise a real kernel rejection of an unavailable attribute, not a fake result. + return source.replace(anchor, "new IntPtr(0x0002FFFF)"); + }); + const result = await install({ file: Bun.which("node")!, + args: ["-e", `require('fs').writeFileSync(${JSON.stringify(launched)}, 'ran')`], options: {} }); + expect(result.status).toBeNull(); + expect(result.processTreeExited).toBe(true); + expect(result.error?.code).toBe("EINSTALLJOIN"); + expect(result.error?.message).toContain("Windows 10 or later"); + expect(result.error?.message).toContain("installer was not started"); + expect(existsSync(launched)).toBe(false); + } finally { rmSync(root, { recursive: true, force: true }); } +}, 20000); diff --git a/tests/update/update-npm-timeout.test.ts b/tests/update/update-npm-timeout.test.ts new file mode 100644 index 00000000000..0d3716a619f --- /dev/null +++ b/tests/update/update-npm-timeout.test.ts @@ -0,0 +1,180 @@ +import { expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { launcherUsableAfterNpmUpdate, transactionalNpmUpdateAsync, UPDATE_OWNER_MARKER } from "../../src/update/transactional-install.mjs"; +import { npmInvocation } from "../../src/update/npm-invocation.mjs"; +import { runNpmInstall } from "../../src/update/npm-install.mjs"; +import { fixturePath, repoPath } from "../helpers/repo-root"; + +function fixture() { + const root = mkdtempSync(join(tmpdir(), "ocx-npm-timeout-é-업데이트-")); + const packageDir = join(root, "opencodex"); + mkdirSync(packageDir); + writeFileSync(join(packageDir, "package.json"), '{"version":"1.0.0"}'); + return { root, packageDir }; +} + +async function readyTimeoutRunner(root: string, receipt: string): Promise { + // Only fixture setup is outside the install clock: first prove the leaf holds + // the stage, then exercise the unchanged 2000 ms timeout/terminate/join path. + // Instrument a private helper copy instead of adding a production test switch. + const helperSource = readFileSync(repoPath("src/update/npm-install-job.ps1"), "utf8"); + const resume = "Require(ResumeThread(process.thread) != UInt32.MaxValue);"; + expect(helperSource.split(resume)).toHaveLength(2); + const receipt64 = Buffer.from(receipt, "utf16le").toString("base64"); + const helper = join(root, "fixture-job.ps1"); + writeFileSync(helper, helperSource.replace(resume, `${resume} + string fixtureReceipt = System.Text.Encoding.Unicode.GetString(Convert.FromBase64String("${receipt64}")); + Stopwatch fixtureStartup = Stopwatch.StartNew(); + while (!System.IO.File.Exists(fixtureReceipt)) { + if (fixtureStartup.ElapsedMilliseconds >= joinMs) throw new Exception("fixture leaf readiness failed"); + Thread.Sleep(10); + }`), "utf8"); + const runnerSource = readFileSync(repoPath("src/update/npm-install.mjs"), "utf8"); + const helperLine = 'const helper = join(dirname(fileURLToPath(import.meta.url)), "npm-install-job.ps1");'; + expect(runnerSource.split(helperLine)).toHaveLength(2); + const instrumented = runnerSource.replace(helperLine, `const helper = ${JSON.stringify(helper)};`); + return (await import("data:text/javascript;base64," + Buffer.from(instrumented).toString("base64"))).runNpmInstall; +} + +test("timeout preserves ETIMEDOUT and permits recovery through the untouched launcher", async () => { + const { root, packageDir } = fixture(); + let stage = ""; + try { + const result = await transactionalNpmUpdateAsync({ + packageDir, pkgName: "@fixture/opencodex", tag: "latest", + runNpm: (args: string[]) => { + stage = args[args.indexOf("--prefix") + 1]!; + return { status: null, error: Object.assign(new Error("npm install exceeded 180000 ms"), { code: "ETIMEDOUT" }), processTreeExited: true }; + }, + }); + expect(result.error).toContain("ETIMEDOUT"); + expect(result.error).toContain("180000 ms"); + expect(result.phase).toBe("stage"); + expect(launcherUsableAfterNpmUpdate(result)).toBe(true); + expect(existsSync(stage)).toBe(false); + expect(readFileSync(join(packageDir, "package.json"), "utf8")).toBe('{"version":"1.0.0"}'); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test.skipIf(process.platform !== "win32").each(["tree", "orphan"])("Windows cmd-wrapped %s is joined before first stage removal", async mode => { + const { root, packageDir } = fixture(); + const receipt = join(root, "ready.json"); + const shimDir = join(root, "npm shim"); + mkdirSync(shimDir); + const node = Bun.which("node")!; + expect(node).toBeTruthy(); + writeFileSync(join(shimDir, "npm.cmd"), `@echo off\r\n"${node}" "${fixturePath("npm-timeout-tree.mjs")}" %*\r\n`, "utf8"); + let pids: number[] = []; + let removals = 0; + let stageRoot = ""; + let treeReady = false; + let aliveAtFirstRemoval: number[] | undefined; + let observedInstall: { processTreeExited?: boolean; error?: { code?: string } } | undefined; + try { + const runReadyInstall = await readyTimeoutRunner(root, receipt); + const result = await transactionalNpmUpdateAsync({ + packageDir, pkgName: "@fixture/opencodex", tag: "latest", + runNpm: async (args: string[]) => { + const stage = args[args.indexOf("--prefix") + 1]!; + stageRoot = stage; + const env = { ...process.env, PATH: shimDir, PATHEXT: ".CMD" }; + const invocation = npmInvocation([mode, stage, receipt], "win32", env, { cwd: packageDir }); + expect(invocation?.file.toLowerCase()).toEndWith("system32\\cmd.exe"); + const install = await runReadyInstall(invocation, { env, timeoutMs: 2000 }); + observedInstall = install; + // Receipt is a handshake from the leaf after acquiring its cwd/file. + // A startup failure cannot masquerade as a passing timeout test. + treeReady = existsSync(receipt); + if (treeReady) pids = JSON.parse(readFileSync(receipt, "utf8")).pids; + return install; + }, + deps: { rm: (path: string, options: { recursive: true; force: true }) => { + removals++; + aliveAtFirstRemoval ??= pids.filter(pid => { + try { process.kill(pid, 0); return true; } + catch (error) { return (error as NodeJS.ErrnoException).code !== "ESRCH"; } + }); + // No retry: the leaf's stage cwd has actually been released. + rmSync(path, options); + } }, + }); + // Assert outside injected callbacks: transaction error handling must not + // swallow fixture assertions and turn a retained stage into a false pass. + expect(treeReady, JSON.stringify(observedInstall)).toBe(true); + expect(pids).toHaveLength(3); + expect(observedInstall?.processTreeExited).toBe(true); + expect(observedInstall?.error?.code).toBe("ETIMEDOUT"); + expect(aliveAtFirstRemoval).toEqual([]); + expect(removals).toBeGreaterThan(0); + expect(existsSync(stageRoot)).toBe(false); + expect(result.error).toContain("ETIMEDOUT"); + expect(launcherUsableAfterNpmUpdate(result)).toBe(true); + expect(readFileSync(join(packageDir, "package.json"), "utf8")).toBe('{"version":"1.0.0"}'); + } finally { + // Only fixture-owned PIDs; clean up even when testing an unfixed runner. + if (existsSync(receipt)) { + for (const pid of JSON.parse(readFileSync(receipt, "utf8")).pids.reverse()) { + try { process.kill(pid, 0); } catch { continue; } + spawnSync(join(process.env.SystemRoot!, "System32", "taskkill.exe"), ["/PID", String(pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" }); + } + } + rmSync(root, { recursive: true, force: true }); + } +}, 20000); + +test.skipIf(process.platform !== "win32").each([["success", 0], ["failure", 23]] as const)("installer preserves %s exit status", async (mode, status) => { + const result = await runNpmInstall({ file: Bun.which("node")!, args: [fixturePath("npm-timeout-tree.mjs"), mode], options: {} }); + expect(result.status).toBe(status); + expect(result.processTreeExited).toBe(true); + expect(result.error).toBeFalsy(); +}, 20000); + +test.skipIf(process.platform !== "win32")("spawn failure is explicit and has no running installer", async () => { + const result = await runNpmInstall({ file: repoPath(".tmp", "missing-npm-executable"), args: [], options: {} }); + expect(result.status).toBeNull(); + expect(result.processTreeExited).toBe(true); + expect(result.error).toBeTruthy(); +}, 20000); + +test.each([null, 0])("unproven process-tree exit with status %s retains stage and marker", async status => { + const { root, packageDir } = fixture(); + let stage = ""; + const removals: string[] = []; + const logs: string[] = []; + try { + const result = await transactionalNpmUpdateAsync({ + packageDir, pkgName: "@fixture/opencodex", tag: "latest", log: (line: string) => logs.push(line), + runNpm: (args: string[]) => { + stage = args[args.indexOf("--prefix") + 1]!; + writeFileSync(join(stage, "still-owned"), "partial download"); + return { status, error: Object.assign(new Error("termination could not be confirmed"), { code: "ETIMEDOUT" }), processTreeExited: false }; + }, + deps: { rm: (path: string) => removals.push(path) }, + }); + expect(removals).toEqual([]); + expect(existsSync(join(stage, UPDATE_OWNER_MARKER))).toBe(true); + expect(existsSync(join(stage, "still-owned"))).toBe(true); + expect(result.phase).toBe("stage"); + expect(logs.join("\n")).toContain("process-tree exit"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("async staging requires affirmative exit evidence, even on status zero", async () => { + const { root, packageDir } = fixture(); + let stage = ""; + try { + const result = await transactionalNpmUpdateAsync({ packageDir, pkgName: "@fixture/opencodex", tag: "latest", + runNpm: (args: string[]) => { stage = args[args.indexOf("--prefix") + 1]!; return { status: 0 }; }, + }); + expect(result.phase).toBe("stage"); + expect(result.error).toContain("EINSTALLJOIN"); + expect(existsSync(join(stage, UPDATE_OWNER_MARKER))).toBe(true); + } finally { rmSync(root, { recursive: true, force: true }); } +}); diff --git a/tests/update/update-stop-first.test.ts b/tests/update/update-stop-first.test.ts index 3485555a38a..d16ae44cafb 100644 --- a/tests/update/update-stop-first.test.ts +++ b/tests/update/update-stop-first.test.ts @@ -820,7 +820,7 @@ esac expect(stopEnvAt).toBeLessThan(replacementAt); expect(launcherSource.slice(lockedPlanAt, stopAt)).toContain("!runtimePlan.mayStopRuntime"); const packageReplacement = launcherSource.slice(stopAt, launcherSource.indexOf("const postInstallPlan", stopAt)); - expect(packageReplacement.match(/unprivilegedOwnershipMutationEnvironment/g)).toHaveLength(2); + expect(packageReplacement.match(/unprivilegedOwnershipMutationEnvironment/g)).toHaveLength(3); // Windows npm, POSIX npm, pnpm. }); test("replacement refusal reaches owner-aware recovery before releasing authority", () => { From 8a3be7716ce61f79d8889cf79f9ea8307434935d Mon Sep 17 00:00:00 2001 From: Northstar Date: Mon, 26 Oct 2026 19:33:10 +0800 Subject: [PATCH 083/101] fix(deepseek): restore official V4 Pro availability (#7591) * fix(deepseek): restore official V4 Pro availability * fix(deepseek): restore Pro Responses terminal repair * fix(deepseek): refresh saved native Pro model rosters * fix(deepseek): accept official v1 roster repair alias * fix(providers): preserve case-folded roster metadata overrides * test(deepseek): include restored V4 Pro in registry-derived expectations CI shards 1/4 and 2/4 failed because two assertions enumerate the DeepSeek registry's no-vision and reasoning-summary lists, which now include deepseek-v4-pro (text-only, summaries supported). --------- Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> --- .../src/content/docs/guides/providers.md | 6 +++ src/codex/catalog/parsing.ts | 12 ++--- src/providers/registry/entries-core.ts | 20 ++++---- src/providers/registry/model-seeds.ts | 15 ++---- src/providers/stale-model-roster-migration.ts | 10 +++- structure/catalog-vendor-context.md | 2 + structure/providers-and-adapters.md | 2 +- .../codex-catalog-exclusions.test.ts | 3 +- tests/codex-integration/codex-catalog.test.ts | 2 +- tests/providers/deepseek-inbound-wire.test.ts | 11 ++++- .../model-roster-seed-repair.test.ts | 46 +++++++++++++++++++ .../provider-registry-parity.test.ts | 17 +++---- tests/routing/router.test.ts | 2 +- 13 files changed, 102 insertions(+), 46 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 9d434d103a9..8032797cb0f 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -675,6 +675,12 @@ selectors, then retry. Signing in from a machine with no existing `kiro-cli` ses The native dashboard choices are add-only and do not sign out `kiro-cli`. A device dialog shows the code and verification destination. Only recognized Kiro or Builder ID hosts are opened as links; an unexpected destination is shown as copyable text for review. Closing the dialog sends cancellation and leaves a bounded background status check to reconcile a commit already in progress. A stalled status response is retried; exhausting the flow deadline produces the neutral ended outcome rather than claiming success. Google and GitHub approval completes when Kiro reports `authorized` (or the legacy `approved` status) with valid tokens and a profile ARN. The account list marks Kiro accounts excluded from automatic selection with a reason, when available. +### DeepSeek API + +The `deepseek` API-key provider includes `deepseek-flash`, its `deepseek-v4-flash` alias, and `deepseek-v4-pro`. DeepSeek [withdrew the Pro retirement plan](https://api-docs.deepseek.com/updates/) and continues serving it; opencodex retains Pro in the catalog with a 1,048,576-token context window, text-only input, and `low`/`high`/`max` reasoning effort. Responses requests use its native Responses endpoint; Chat Completions requests retain the Chat wire. Flash remains the default. Third-party providers have independent model availability. + +On upgrade, startup adds Pro to a saved model list only when it exactly matches the previous built-in DeepSeek seed and still uses the official endpoint and adapter. Customized lists, including empty lists, remain unchanged; live discovery need not be enabled for this repair. + ### DeepSeek Account (DSH) `dsh-account` is an experimental, import-first account provider designed to reuse the authenticated account credentials of an installed DeepSeek Harness (DSH) Desktop application. diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index bdab81c61e3..27693aa4cdc 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -184,15 +184,11 @@ export const ROUTED_MODEL_COMPATIBILITY_EXCLUSIONS = new Set([ "opencode-go/mimo-v2-omni", "opencode-go/mimo-v2-pro", /* - * DeepSeek retired `deepseek-v4-pro` on 2026-09-14 04:00 UTC and routes its requests to - * V4.1-Flash (api-docs.deepseek.com/news/news260910). Deleting the registry rows removes - * the model on providers that publish a static roster, but every provider below discovers - * its models live — there, a deleted row does not remove anything, it only strips the - * context window, the effort ladder and the text-only hint, so the retired model would - * keep appearing with its capabilities broken. Excluding the slug is what actually takes - * it out of the routed catalog. + * These third-party exclusions originated in the superseded V4 Pro retirement plan. + * First-party service continues (api-docs.deepseek.com/updates/); these gateway routes + * remain excluded pending provider-specific verification. Live discovery can reintroduce + * a model removed from a static registry, so these exclusions act on the routed catalog. */ - "deepseek/deepseek-v4-pro", "opencode-go/deepseek-v4-pro", "command-code/deepseek-deepseek-v4-pro", "commandcode/deepseek-deepseek-v4-pro", diff --git a/src/providers/registry/entries-core.ts b/src/providers/registry/entries-core.ts index fa0e4f918d4..61cad3e3344 100644 --- a/src/providers/registry/entries-core.ts +++ b/src/providers/registry/entries-core.ts @@ -1256,17 +1256,18 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ // the list only as compatibility aliases so existing saved configs and requests // keep validating and routing (they previously mapped to v4-flash; devlog // _fin/260710_provider_hardening/002_research_cn.md). The current offerings are - // V4.1-Flash — defaultModel and the model-specific wiring below use its live id. + // V4.1-Flash and V4 Pro; Flash remains the default. // Keep the legacy vision-preview alias; see DEEPSEEK_VISION_PREVIEW_MODEL. models: ["deepseek-chat", "deepseek-reasoner", ...DEEPSEEK_NATIVE_THINKING_MODELS, DEEPSEEK_VISION_PREVIEW_MODEL], - // V4.1-Flash is the current first-party offering; `deepseek-v4-flash` now routes there + // V4.1-Flash is the first-party default; `deepseek-v4-flash` now routes there // as a compatibility alias, so a new install should ask for the live id by name. defaultModel: "deepseek-flash", // Official DeepSeek Codex setup (codex-deepseek-setup.sh) advertises 1,048,576 // for both V4 models; the older 1,000,000 figure was a rounded approximation. - modelContextWindows: { "deepseek-flash": 1_048_576, "deepseek-v4-flash": 1_048_576, [DEEPSEEK_VISION_PREVIEW_MODEL]: 1_048_576 }, + modelContextWindows: { "deepseek-flash": 1_048_576, "deepseek-v4-flash": 1_048_576, "deepseek-v4-pro": 1_048_576, [DEEPSEEK_VISION_PREVIEW_MODEL]: 1_048_576 }, modelInputModalities: { "deepseek-flash": ["text", "image"], + "deepseek-v4-pro": ["text"], [DEEPSEEK_VISION_PREVIEW_MODEL]: ["text", "image"], }, // DeepSeek documents both V4 models as native Responses API models adapted for Codex @@ -1281,6 +1282,7 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ // translating them into Responses would add a hop onto our newest upstream path // for no gain. "deepseek-v4-flash": { wire: "openai-responses", inbound: ["responses"] }, + "deepseek-v4-pro": { wire: "openai-responses", inbound: ["responses"] }, // Same Responses contract as the V4 ids it succeeds; without this row the new // default would fall back to the provider-wide Chat wire. "deepseek-flash": { wire: "openai-responses", inbound: ["responses"] }, @@ -1298,7 +1300,7 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ // devlog/_fin/260807_deepseek_responses_streaming/000_plan.md. // Current official streams normally carry a real terminal; retain a narrow grace // repair for the historical shape that closes after a complete graph without one. - modelResponsesTerminalRepair: { "deepseek-flash": { graceMs: 5_000 }, "deepseek-v4-flash": { graceMs: 5_000 } }, + modelResponsesTerminalRepair: { "deepseek-flash": { graceMs: 5_000 }, "deepseek-v4-flash": { graceMs: 5_000 }, "deepseek-v4-pro": { graceMs: 5_000 } }, // DeepSeek's Responses route emits bare UUID item ids, which leave Codex // clients stuck on an uncommitted turn (#938). Client-facing only — raw // continuation snapshots keep the upstream ids. @@ -1337,16 +1339,12 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ modelReasoningEfforts: Object.fromEntries(DEEPSEEK_NATIVE_THINKING_MODELS.map(id => [id, deepseekThinkingEffortsFor(id)])), modelReasoningEffortMap: Object.fromEntries(DEEPSEEK_NATIVE_THINKING_MODELS.map(id => [id, deepseekReasoningMapFor(id)])), modelSupportsReasoningSummaries: Object.fromEntries(DEEPSEEK_NATIVE_THINKING_MODELS.map(id => [id, true])), - // deepseek-v4-pro is absent from the live sets on purpose (retired upstream, requests - // route to V4.1-Flash — see model-seeds.ts), but it stays SELECTABLE in configs that - // already carry it, and what serves it is a thinking-mode model. A saved config with - // the alias selected would otherwise serialize a bare continuation the upstream 400s - // on (#5421). The preserve list governs replay, not advertisement. - preserveReasoningContentModels: [...DEEPSEEK_NATIVE_THINKING_MODELS, "deepseek-v4-pro"], + // Native thinking models retain assistant reasoning on tool-call continuations (#5421). + preserveReasoningContentModels: DEEPSEEK_NATIVE_THINKING_MODELS, // #4436: first-party deepseek-flash accepts native images on Chat and Responses. // Keep unprobed compatibility aliases on the #88 sidecar path. This must be fixed // here: router enrichment unions this list with saved config, so config cannot remove it. - noVisionModels: ["deepseek-chat", "deepseek-reasoner", "deepseek-v4-flash"], + noVisionModels: ["deepseek-chat", "deepseek-reasoner", "deepseek-v4-flash", "deepseek-v4-pro"], }, // llama-3.3-70b was deprecated by Cerebras on 2026-02-16. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md. { id: "cerebras", label: "Cerebras", baseUrl: "https://api.cerebras.ai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://cloud.cerebras.ai/platform/apikeys", defaultModel: "gpt-oss-120b" }, diff --git a/src/providers/registry/model-seeds.ts b/src/providers/registry/model-seeds.ts index ac1088df1c9..0ef89283bc4 100644 --- a/src/providers/registry/model-seeds.ts +++ b/src/providers/registry/model-seeds.ts @@ -377,8 +377,8 @@ export const OPENCODE_GO_THINKING_BUDGET_MODELS = ["qwen3.5-plus", "qwen3.6-plus /* * DeepSeek moved the whole V4 name set on 2026-09-10. V4.1-Flash ships as deepseek-flash * on the first-party API; deepseek-v4-flash and the vision preview retire as models but - * keep routing there as compatibility aliases, and deepseek-v4-pro follows from - * 2026-09-14 04:00 UTC. Evidence: https://api-docs.deepseek.com/news/news260910/. + * keep routing there as compatibility aliases. V4 Pro remains available: the planned + * retirement was withdrawn. Evidence: https://api-docs.deepseek.com/updates/ (2026-10-23). * * The spelling differs by who serves it, so one shared list cannot express it: the * first-party API answers to deepseek-flash, while the Zen gateway exposes the route as @@ -388,15 +388,10 @@ export const OPENCODE_GO_THINKING_BUDGET_MODELS = ["qwen3.5-plus", "qwen3.6-plus */ export const DEEPSEEK_V4_LEGACY_MODELS = ["deepseek-v4-flash"]; /* - * `deepseek-v4-pro` is deliberately absent from both live sets. DeepSeek retires it from - * 2026-09-14 04:00 UTC and routes its requests to V4.1-Flash until a V4.1 Pro exists, so a - * row here would advertise a Pro context window and Pro pricing for a route that serves - * Flash. The retirement is followed through every roster in this file, including the - * vendor-hosted ones; providers that discover their models live are handled by - * `ROUTED_MODEL_COMPATIBILITY_EXCLUSIONS` because deleting a row there removes the - * model's capabilities rather than the model. + * The official API continues serving V4 Pro after September 14 with unchanged billing. + * Gateway availability is independent; restoring the native set does not change gateway rosters. */ -export const DEEPSEEK_NATIVE_THINKING_MODELS = ["deepseek-flash", "deepseek-v4-flash"]; +export const DEEPSEEK_NATIVE_THINKING_MODELS = ["deepseek-flash", "deepseek-v4-flash", "deepseek-v4-pro"]; export const DEEPSEEK_GATEWAY_THINKING_MODELS = ["deepseek-v4.1-flash", "deepseek-v4-flash"]; /* * DeepSeek's legacy vision preview id (released 2026-08-21). First-party probes diff --git a/src/providers/stale-model-roster-migration.ts b/src/providers/stale-model-roster-migration.ts index 6b627cd5840..9ab1b24fc1a 100644 --- a/src/providers/stale-model-roster-migration.ts +++ b/src/providers/stale-model-roster-migration.ts @@ -37,6 +37,7 @@ import { MINIMAX_MODEL_CONTEXT_WINDOWS, } from "./registry/model-seeds"; import type { OcxConfig } from "../types"; +import { modelRecordValue } from "../reasoning-effort"; export interface StaleModelRoster { /** Registry provider id whose saved rows may carry the old roster. */ @@ -92,6 +93,13 @@ export const STALE_MODEL_ROSTERS: readonly StaleModelRoster[] = [ // (preserveCustomDestination); only api.meta.ai publishes the roster `to` describes. destinationBaseUrls: ["https://api.meta.ai/v1"], }, + { + provider: "deepseek", + from: ["deepseek-chat", "deepseek-reasoner", "deepseek-flash", "deepseek-v4-flash", "deepseek-v4-flash-vision-exp"], + to: ["deepseek-chat", "deepseek-reasoner", "deepseek-flash", "deepseek-v4-flash", "deepseek-v4-pro", "deepseek-v4-flash-vision-exp"], + contextWindows: { "deepseek-v4-pro": 1_048_576 }, + destinationBaseUrls: ["https://api.deepseek.com", "https://api.deepseek.com/v1"], + }, ...["minimax", "minimax-cn"].map(provider => ({ provider, from: MINIMAX_MODELS_BEFORE_M31, @@ -148,7 +156,7 @@ function fillExisting(container: Record | undefined, values: Reado let filled = false; for (const id of ids) { const value = values[id]; - if (value !== undefined && !Object.prototype.hasOwnProperty.call(container, id)) { + if (value !== undefined && modelRecordValue(container, id) === undefined) { container[id] = value; filled = true; } diff --git a/structure/catalog-vendor-context.md b/structure/catalog-vendor-context.md index 2481d17e29f..555085a0cdc 100644 --- a/structure/catalog-vendor-context.md +++ b/structure/catalog-vendor-context.md @@ -43,3 +43,5 @@ Compaction and max input still follow the effective default window, including retained lower soft budgets. Native capacity remains defined by the [native passthrough contract](catalog.md#native-passthrough). Covered by `tests/codex-integration/catalog-native-max-context.test.ts`. + +The first-party DeepSeek catalog retains `deepseek-v4-pro`: its retirement plan was withdrawn, and the official models endpoint still advertises it. `src/codex/catalog/parsing.ts` does not exclude that first-party row; third-party exclusions remain provider-specific. `src/providers/registry/entries-core.ts` restores its text-only capability, context window, reasoning controls and inbound-scoped Responses route while retaining Flash as the default. Evidence: [official service continuation notice](https://api-docs.deepseek.com/updates/), checked 2026-10-23. diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index ad088a20e0b..85a331ebe4f 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -391,7 +391,7 @@ axis that outranks every source here, so it is where a deliberate text-only over (`ocx provider edit --model --text-only` writes it) and the one declaration a restart cannot take back. -When `/models` omits MiniMax-M3.1-Flash-Preview, `src/providers/stale-model-roster-migration.ts` replaces only a saved roster identical to the old seed and fills existing window and default-effort records during startup; `CALLABLE_CONFIGURED_COMPATIBILITY_MODELS` (`src/codex/catalog/model-hints.ts`) retains it in the live catalog. +When `/models` omits MiniMax-M3.1-Flash-Preview, `src/providers/stale-model-roster-migration.ts` replaces only a saved roster identical to the old seed and fills existing window and default-effort records during startup; `CALLABLE_CONFIGURED_COMPATIBILITY_MODELS` (`src/codex/catalog/model-hints.ts`) retains it in the live catalog. The same startup projection restores DeepSeek V4 Pro only for the exact previous native seed on the official destination and registry adapter, filling missing Pro context in existing maps. Operator lists (including empty lists), custom destinations and explicit context overrides remain unchanged; normal registry enrichment supplies Pro's reasoning metadata. Both MiniMax Coding Plan presets declare `text` and `image` for M3 and M3.1 in `src/providers/registry/model-seeds.ts`; registry enrichment fills missing saved declarations, so id-only live rows enable the Combo image switch when every target supports images. Explicit overrides still win; video is outside Codex's catalog input enum. diff --git a/tests/codex-integration/codex-catalog-exclusions.test.ts b/tests/codex-integration/codex-catalog-exclusions.test.ts index 747196a0ea5..7bfc86eff73 100644 --- a/tests/codex-integration/codex-catalog-exclusions.test.ts +++ b/tests/codex-integration/codex-catalog-exclusions.test.ts @@ -7,7 +7,8 @@ describe("routed catalog compatibility exclusions", () => { // Issue #2330: uncallable or stale OpenCode Go models expect(shouldExposeRoutedModel({ provider: "opencode-go", id: "mimo-v2-omni" })).toBe(false); expect(shouldExposeRoutedModel({ provider: "opencode-go", id: "mimo-v2-pro" })).toBe(false); - expect(shouldExposeRoutedModel({ provider: "deepseek", id: "deepseek-v4-pro" })).toBe(false); + // First-party retirement was withdrawn; gateway availability remains independent. + expect(shouldExposeRoutedModel({ provider: "deepseek", id: "deepseek-v4-pro" })).toBe(true); expect(shouldExposeRoutedModel({ provider: "opencode-go", id: "deepseek-v4-pro" })).toBe(false); expect(shouldExposeRoutedModel({ provider: "opencode-go", id: "grok-4.6" })).toBe(true); expect(shouldExposeRoutedModel({ provider: "opencode-go", id: "glm-5.2" })).toBe(true); diff --git a/tests/codex-integration/codex-catalog.test.ts b/tests/codex-integration/codex-catalog.test.ts index 01018f767e8..62c5350666d 100644 --- a/tests/codex-integration/codex-catalog.test.ts +++ b/tests/codex-integration/codex-catalog.test.ts @@ -6266,7 +6266,7 @@ describe("Codex catalog routed normalization", () => { expect(provider.modelSupportsReasoningSummaries).toEqual({ "deepseek-v4-flash": false, - "deepseek-flash": true, + "deepseek-flash": true, "deepseek-v4-pro": true, }); }); diff --git a/tests/providers/deepseek-inbound-wire.test.ts b/tests/providers/deepseek-inbound-wire.test.ts index 72b6b9c2f17..44612a18025 100644 --- a/tests/providers/deepseek-inbound-wire.test.ts +++ b/tests/providers/deepseek-inbound-wire.test.ts @@ -112,6 +112,12 @@ function deepseekReasoningProvider(): OcxProviderConfig { } describe("DeepSeek wire selection is scoped to the inbound protocol", () => { + test("restored Pro preserves native Responses and translated Chat dispatch", () => { + for (const inbound of ["responses", "chat", "anthropic"] as const) { + expect(resolveWireProtocolOverride("deepseek", "deepseek-v4-pro", deepseekProvider(), inbound).adapter) + .toBe(inbound === "responses" ? "openai-responses" : "openai-chat"); + } + }); test("a Responses inbound rides the native Responses wire", () => { const resolved = resolveWireProtocolOverride("deepseek", MODEL, deepseekProvider(), "responses"); expect(resolved.adapter).toBe("openai-responses"); @@ -153,6 +159,7 @@ describe("DeepSeek wire selection is scoped to the inbound protocol", () => { test("the official DeepSeek Responses route opts into terminal repair", () => { const provider = deepseekProvider(); expect(providerModelResponsesTerminalRepair("deepseek", provider, MODEL)).toEqual({ graceMs: 5_000 }); + expect(providerModelResponsesTerminalRepair("deepseek", provider, "deepseek-v4-pro")).toEqual({ graceMs: 5_000 }); expect(providerModelResponsesTerminalRepair("deepseek", provider, "deepseek-chat")).toBeUndefined(); expect(providerModelResponsesTerminalRepair("custom-deepseek", provider, MODEL)).toBeUndefined(); }); @@ -248,7 +255,7 @@ describe("the inbound scope survives the handleResponses replay", () => { expect(request.body.stream).toBe(true); }); - test("HTTP streams a DeepSeek delta before safely repairing a missing terminal", async () => { + test.each([MODEL, "deepseek-v4-pro"])("HTTP streams %s before safely repairing a missing terminal", async (model) => { const source = controlledSse(); const scheduler = new ManualTerminalScheduler(); const requestBodies: Record[] = []; @@ -270,7 +277,7 @@ describe("the inbound scope survives the handleResponses replay", () => { new Request("http://localhost/v1/responses", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ model: MODEL, input: "ping", stream: true }), + body: JSON.stringify({ model, input: "ping", stream: true }), }), config, { model: "", provider: "" }, diff --git a/tests/providers/model-roster-seed-repair.test.ts b/tests/providers/model-roster-seed-repair.test.ts index 0c7c3d390d4..7d48fc2b229 100644 --- a/tests/providers/model-roster-seed-repair.test.ts +++ b/tests/providers/model-roster-seed-repair.test.ts @@ -13,10 +13,56 @@ import { MINIMAX_MODELS_BEFORE_M31, } from "../../src/providers/registry/model-seeds"; import { PROVIDER_REGISTRY } from "../../src/providers/registry"; +import { enrichProviderFromRegistry } from "../../src/providers/derive"; import type { OcxConfig, OcxProviderConfig } from "../../src/types"; const PREVIEW = "MiniMax-M3.1-Flash-Preview"; +describe("DeepSeek Pro saved roster refresh", () => { + const before = ["deepseek-chat", "deepseek-reasoner", "deepseek-flash", "deepseek-v4-flash", "deepseek-v4-flash-vision-exp"]; + const configFor = (models = before, baseUrl = "https://api.deepseek.com") => ({ providers: { deepseek: { + adapter: "openai-chat", baseUrl, models: [...models], liveModels: false, + defaultModel: "deepseek-flash", modelContextWindows: { "deepseek-flash": 1_048_576 }, + } } } as unknown as OcxConfig); + + test.each(["https://api.deepseek.com", "https://api.deepseek.com/v1", "https://api.deepseek.com/v1/"])("startup restores the native seed without live discovery at %s", (baseUrl) => { + const first = projectStartupConfigRepairs(configFor(before, baseUrl)); + const row = first.config.providers!.deepseek!; + expect(first.changed).toBe(true); + expect(row.models).toContain("deepseek-v4-pro"); + expect(row.modelContextWindows?.["deepseek-v4-pro"]).toBe(1_048_576); + expect(row.liveModels).toBe(false); + expect(row.baseUrl).toBe(baseUrl); + expect(row.defaultModel).toBe("deepseek-flash"); + expect(projectStartupConfigRepairs(structuredClone(first.config)).changed).toBe(false); + enrichProviderFromRegistry("deepseek", row); + expect(row.modelReasoningEfforts?.["deepseek-v4-pro"]).toEqual(["low", "high", "max"]); + expect(row.modelSupportsReasoningSummaries?.["deepseek-v4-pro"]).toBe(true); + }); + + test("preserves operator lists, custom destinations and adapters", () => { + for (const models of [[], before.slice(1), [...before].reverse(), [...before, "custom-model"]]) { + const config = configFor(models); + expect(projectStaleModelRosters(config).changed).toBe(false); + expect(config.providers!.deepseek!.models).toEqual(models); + } + expect(projectStaleModelRosters(configFor(before, "https://example.com")).changed).toBe(false); + const config = configFor(); + config.providers!.deepseek!.adapter = "anthropic"; + expect(projectStaleModelRosters(config).changed).toBe(false); + }); + + test.each(["deepseek-v4-pro", "DeepSeek-V4-Pro"])("preserves explicit Pro context under %s and absent metadata containers", (key) => { + const config = configFor(); + config.providers!.deepseek!.modelContextWindows![key] = 123; + const windows = { ...config.providers!.deepseek!.modelContextWindows }; + expect(projectStaleModelRosters(config).config.providers!.deepseek!.modelContextWindows).toEqual(windows); + const absent = configFor(); + delete absent.providers!.deepseek!.modelContextWindows; + expect(projectStaleModelRosters(absent).config.providers!.deepseek!.modelContextWindows).toBeUndefined(); + }); +}); + function minimaxConfig(provider: string, row: Partial): OcxConfig { return { providers: { diff --git a/tests/providers/provider-registry-parity.test.ts b/tests/providers/provider-registry-parity.test.ts index 68787660838..b950f35b714 100644 --- a/tests/providers/provider-registry-parity.test.ts +++ b/tests/providers/provider-registry-parity.test.ts @@ -372,13 +372,12 @@ describe("provider registry parity", () => { expect(KEY_LOGIN_PROVIDERS.openrouter.modelContextWindows?.["openai/gpt-5.6-sol"]).toBe(1_050_000); expect(KEY_LOGIN_PROVIDERS.openrouter.modelContextWindows?.["openai/gpt-5.6-terra"]).toBe(1_050_000); expect(KEY_LOGIN_PROVIDERS.openrouter.modelContextWindows?.["openai/gpt-5.6-luna"]).toBe(1_050_000); - // Retired from the first-party API on 2026-09-14; the vendor-hosted rosters keep it. - expect(KEY_LOGIN_PROVIDERS.deepseek.models).not.toContain("deepseek-v4-pro"); + // The official retirement plan was withdrawn; Pro remains a separate text-only model. + expect(KEY_LOGIN_PROVIDERS.deepseek.models).toContain("deepseek-v4-pro"); + expect(KEY_LOGIN_PROVIDERS.deepseek.modelContextWindows?.["deepseek-v4-pro"]).toBe(1_048_576); + expect(KEY_LOGIN_PROVIDERS.deepseek.modelReasoningEfforts?.["deepseek-v4-pro"]).toEqual(["low", "high", "max"]); expect(KEY_LOGIN_PROVIDERS.deepseek.models).toContain("deepseek-flash"); - // #1057: DeepSeek's ladder is low/high/max and the two V4 models resolve it - // differently (api-docs.deepseek.com/guides/thinking_mode, verified 2026-08-06). - // `xhigh` is an alias, so it stays in the wire map but is not advertised. Pro - // does not honor `low` (the vendor maps it to `high`), so Pro must not offer it. + // The current Thinking Mode guide declares low/high/max; aliases stay in the wire map. expect(KEY_LOGIN_PROVIDERS.deepseek.modelReasoningEfforts?.["deepseek-flash"]).toEqual(["low", "high", "max"]); expect(KEY_LOGIN_PROVIDERS.deepseek.modelReasoningEfforts?.["deepseek-v4-flash"]).toEqual(["low", "high", "max"]); expect(KEY_LOGIN_PROVIDERS.deepseek.modelReasoningEffortMap?.["deepseek-flash"]?.low).toBe("low"); @@ -387,14 +386,12 @@ describe("provider registry parity", () => { expect(KEY_LOGIN_PROVIDERS.deepseek.modelReasoningEffortMap?.["deepseek-v4-flash"]?.low).toBe("low"); expect(KEY_LOGIN_PROVIDERS.deepseek.modelReasoningEffortMap?.["deepseek-v4-flash"]?.xhigh).toBe("high"); expect(KEY_LOGIN_PROVIDERS.deepseek.modelReasoningEffortMap?.["deepseek-v4-flash"]?.max).toBe("max"); - // The retired deepseek-v4-pro stays OUT of `models` (line 357) but inside the preserve - // list: it still routes to a thinking-mode model, so a saved config that carries it - // must get reasoning replay rather than a guaranteed 400 (#5421). + // Pro and Flash both retain reasoning on tool-call continuations (#5421). expect(KEY_LOGIN_PROVIDERS.deepseek.preserveReasoningContentModels) .toEqual(["deepseek-flash", "deepseek-v4-flash", "deepseek-v4-pro"]); // #4436: first-party Flash accepts images; unprobed compatibility aliases keep the sidecar. expect(KEY_LOGIN_PROVIDERS.deepseek.noVisionModels).toEqual([ - "deepseek-chat", "deepseek-reasoner", "deepseek-v4-flash", + "deepseek-chat", "deepseek-reasoner", "deepseek-v4-flash", "deepseek-v4-pro", ]); }); diff --git a/tests/routing/router.test.ts b/tests/routing/router.test.ts index 239461bc01c..ee031439e81 100644 --- a/tests/routing/router.test.ts +++ b/tests/routing/router.test.ts @@ -451,7 +451,7 @@ describe("routeModel registry effort defaults", () => { const route = routeModel(config, "deepseek/deepseek-v4-flash"); expect(route.provider.noVisionModels).toEqual([ - "deepseek-chat", "deepseek-reasoner", "deepseek-v4-flash", + "deepseek-chat", "deepseek-reasoner", "deepseek-v4-flash", "deepseek-v4-pro", ]); }); From daec176a1ca7a7185be25332cbb95babec358ac0 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Mon, 26 Oct 2026 11:50:07 +0000 Subject: [PATCH 084/101] fix(live): bound WebSocket close without releasing live ownership (#7635) After the one-second grace period, terminate unresponsive attached and pre-upgrade live-sideband upstream transports on Bun 1.4.4. Admission ownership is released only after a close event or an observed CLOSED state; missing, throwing or ineffective termination retains the lease. Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- .../docs/ko/reference/proxy-formats.md | 4 + .../content/docs/reference/proxy-formats.md | 4 + scripts/test-layout/layout-clients.json | 1 + src/server/index/live-sideband.ts | 33 +-- src/server/ws-bridge.ts | 2 +- structure/runtime.md | 2 +- .../codex-stored-main-duplicate.test.ts | 1 + tests/fixtures/test-layout-expected.json | 1 + tests/server/server-live-close.test.ts | 188 ++++++++++++++++++ tests/server/server-live.test.ts | 85 ++++---- 10 files changed, 267 insertions(+), 54 deletions(-) create mode 100644 tests/server/server-live-close.test.ts diff --git a/docs-site/src/content/docs/ko/reference/proxy-formats.md b/docs-site/src/content/docs/ko/reference/proxy-formats.md index 32963d7f7c3..bf854d93543 100644 --- a/docs-site/src/content/docs/ko/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ko/reference/proxy-formats.md @@ -294,6 +294,10 @@ call creation 이후 클라이언트는 다음의 지원되는 모든 inbound 프록시는 업스트림 join URL을 정규화한 뒤, 양방향 텍스트 및 바이너리 프레임을 투명하게 릴레이합니다. 업스트림 인증은 프록시가 소유한 상태로 유지되며, 클라이언트 프로토콜 헤더는 보존됩니다. +sideband 연결을 종료할 때 프록시는 업스트림의 정상 종료를 1초 동안 기다린 뒤, 응답하지 않는 +전송 연결을 강제로 닫습니다. 클라이언트 업그레이드가 실패한 경우에도 실제 업스트림 연결이 +닫힌 것을 확인한 후에만 해당 계정의 활성 통화 용량을 반환합니다. + call creation과 sideband join은 같은 OpenAI 계정으로 이루어져야 하며, 그렇지 않으면 업스트림이 join을 거부합니다(`404`). 두 요청 모두 Codex의 `session-id`와 `thread-id` 헤더를 실어 보냅니다. Pool 모드는 계정 선택을 그 쌍에 묶어 두므로(프로세스 로컬) 프록시에 도착한 join은 통화를 만든 계정을 그대로 쓰고, diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index bf4d17fe15b..d0b47e2e9ed 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -102,6 +102,10 @@ returns 504, and client cancellation returns 499. Bun does not expose the exact cannot currently be forwarded precisely. A successful connection preserves the initial session frames in order. This handshake policy is separate from the Responses WebSocket transport. +When a sideband connection ends, the proxy allows one second for a graceful upstream +close before terminating an unresponsive transport. It releases the account's active-call +capacity only after that transport has closed, including when the client upgrade fails. + ## Undeclared tool calls On the Responses endpoint, when a request declares a tool catalog, a native passthrough reply diff --git a/scripts/test-layout/layout-clients.json b/scripts/test-layout/layout-clients.json index 34a42e679da..bf99d076825 100644 --- a/scripts/test-layout/layout-clients.json +++ b/scripts/test-layout/layout-clients.json @@ -1,4 +1,5 @@ { + "server-live-close.test.ts": "server", "link-diagnostics.test.ts": "clients", "link-tunnel-history.test.ts": "clients", "link-readiness.test.ts": "clients", diff --git a/src/server/index/live-sideband.ts b/src/server/index/live-sideband.ts index d0840bdc626..0f022efb6bc 100644 --- a/src/server/index/live-sideband.ts +++ b/src/server/index/live-sideband.ts @@ -184,6 +184,19 @@ function finalizeLiveSideband(ws: ServerWebSocket, upstream?: WebSocket) finally { releaseLiveSidebandAdmission(ws); } } +function forceCloseLiveSidebandUpstream(upstream: WebSocket): void { + // Bun 1.4.4 waits for the peer's closing handshake; another close() while + // CLOSING does not finish it. Stop the transport after the graceful window, + // but leave admission release to the close event or an observed CLOSED state. + try { + const socket = upstream as WebSocket & { terminate?: () => void }; + if (typeof socket.terminate === "function") socket.terminate(); + else upstream.close(1000, "upstream close timeout"); + } catch { + /* retain ownership until transport closure is confirmed */ + } +} + function armLiveSidebandCloseFallback(ws: ServerWebSocket, upstream: WebSocket): void { if (ws.data.liveCloseFallback !== undefined) return; ws.data.liveCloseFallback = setTimeout(() => { @@ -193,13 +206,7 @@ function armLiveSidebandCloseFallback(ws: ServerWebSocket, upstream: Web finalizeLiveSideband(ws, upstream); return; } - // A close frame was already sent below. Retry once, but never surrender - // native-main ownership while the authenticated transport remains live. - try { - upstream.close(1000, "upstream close timeout"); - } catch { - /* upstream is already unusable */ - } + forceCloseLiveSidebandUpstream(upstream); // Some implementations transition synchronously without delivering the // close event. That is still an observed CLOSED transport and is safe to // finalize. CONNECTING/CLOSING peers keep the lease so profile switching @@ -238,17 +245,13 @@ export function closeLiveSidebandBeforeUpgrade( releaseOnce(); return; } - try { - upstream.close(1000, "upstream close timeout"); - } catch { - /* retain ownership until CLOSED is observed */ - } + forceCloseLiveSidebandUpstream(upstream); if ((upstream.readyState as number) === 3) releaseOnce(); }, LIVE_SIDEBAND_CLOSE_FALLBACK_MS); try { upstream.close(code, reason); } catch { - /* the bounded fallback retries without releasing ownership */ + /* the bounded fallback stops the transport before releasing ownership */ } if ((upstream.readyState as number) === 3) releaseOnce(); } @@ -272,12 +275,12 @@ export function closeLiveSideband(ws: ServerWebSocket, code = 1000, reas // The sideband holds a native-main admission lease. Do not release it just // because the downstream left: its authenticated upstream remains live // until the close event arrives or the transport is observed CLOSED. The - // bounded fallback only retries close; it does not release ownership. + // bounded fallback terminates the transport; it cannot release a live lease. armLiveSidebandCloseFallback(ws, upstream); try { upstream.close(code, reason); } catch { - /* the fallback retries close without releasing ownership */ + /* the fallback stops the transport without releasing a live lease */ } } try { diff --git a/src/server/ws-bridge.ts b/src/server/ws-bridge.ts index 6a10afe729e..a2ae495c10e 100644 --- a/src/server/ws-bridge.ts +++ b/src/server/ws-bridge.ts @@ -64,7 +64,7 @@ export interface WsData { liveUpstreamHandoff?: LiveSidebandUpstreamHandoff; /** Once teardown starts, ignore new client frames until the upstream closes. */ liveClosing?: boolean; - /** Schedules one bounded close retry without surrendering native-main ownership. */ + /** Terminates a stalled close without releasing ownership before transport closure. */ liveCloseFallback?: ReturnType; /** Turn/account ownership retained for the complete sideband socket lifetime. */ liveTurnAdmissionLease?: AdmissionLease; diff --git a/structure/runtime.md b/structure/runtime.md index 4ab0293778f..9c1157d9b4b 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -430,7 +430,7 @@ Responses route normalization resolves provider summary defaults from the origin `src/server/index/serve-options.ts` establishes the authorized upstream live sideband before accepting the client WebSocket upgrade, and `src/server/index/live-sideband.ts` implements the bounded upstream dial. `openLiveSidebandUpstream` bounds the handshake to ten seconds and retains at most 32 frames and 1 MiB of preamble within the frame limit. `src/server/ws-bridge.ts` defines the runtime handoff carrying captured frames or terminal state. Failed handshakes return 502/504 and client cancellation returns 499; exact upstream 404/410 status is unavailable from Bun's client WebSocket. Admission ownership lasts until upstream close/CLOSED, including failed upgrades and failed attachment. The ordinary Responses WebSocket exchange remains separate. -The relay is transparent in both directions, and that includes the close: a downstream client's close code and reason are carried to the upstream through `clientCloseForUpstream`, which only substitutes 1000 for a code no endpoint may send and truncates the reason to the 123-byte control-frame limit. This matters to the caller, because a Frameless v3 client reads upstream 1000 as the session completing and any other code as a transport loss to reconnect. +The relay is transparent in both directions, and that includes the close: a downstream client's close code and reason are carried to the upstream through `clientCloseForUpstream`, which only substitutes 1000 for a code no endpoint may send and truncates the reason to the 123-byte control-frame limit. This matters to the caller, because a Frameless v3 client reads upstream 1000 as the session completing and any other code as a transport loss to reconnect. Both pre-upgrade and attached teardown allow one second for graceful closure, then call Bun's `terminate()`. Admission releases only after close/CLOSED; missing or failing termination cannot release a live lease, and a missing method receives one close retry. `tests/server/server-live-close.test.ts` covers silent real TCP peers on Bun 1.4.4, graceful peers and unconfirmed closure. `OCX_LIVE_FRAME_LOG` records both frame metadata and sideband lifecycle stages (`upstream-open`, `upstream-failed`, `relay-attached`, `relay-closed`) in one JSONL, content-free in both shapes. The lifecycle half is what separates a join that never reached this proxy from one whose upstream handshake was refused and from a live relay that carried nothing; frame records alone leave all three as an empty file. `tests/server/server-live-realtime-fixtures.test.ts` drives each sideband stage against de-identified Frameless v3 fixtures in `tests/fixtures/realtime-voice-sideband/` so a failure names the stage. diff --git a/tests/codex-integration/codex-stored-main-duplicate.test.ts b/tests/codex-integration/codex-stored-main-duplicate.test.ts index daba4f87eaf..765d27957fb 100644 --- a/tests/codex-integration/codex-stored-main-duplicate.test.ts +++ b/tests/codex-integration/codex-stored-main-duplicate.test.ts @@ -104,6 +104,7 @@ describe("stored Pool duplicate of native main", () => { saveCodexAccountCredential(poolId, { accessToken: jwt("plus", email, "other-user"), refreshToken: "fake-refresh", chatgptAccountId: "shared-account", expiresAt: Date.now() + 3600_000 }); const changed = await runPassiveRead(() => listCodexAuthAccounts(config)); + expect(changed.find(row => row.id === poolId)?.upstreamAccountKey).toMatch(/^uai1_[0-9a-f]{64}$/); expect(changed.find(row => row.id === poolId)?.upstreamAccountKey).not.toBe(main.upstreamAccountKey); expect(changed.find(row => row.id === "__main__")?.upstreamAccountKey).toBe(main.upstreamAccountKey); }); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 385d2a59d16..98c3b91b81e 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1180,6 +1180,7 @@ "combo-requested-effort.test.ts": "server", "server-kiro-device-login.test.ts": "server", "server-kiro-oauth-401-replay.test.ts": "server", "server-kiro-refusal-e2e.test.ts": "server", "server-live-existing-call.test.ts": "server", + "server-live-close.test.ts": "server", "server-live-frame-log.test.ts": "server", "server-live-realtime-fixtures.test.ts": "server", "server-live.test.ts": "server", "server-loopback-host-gate.test.ts": "server", "server-management-auth.test.ts": "server", diff --git a/tests/server/server-live-close.test.ts b/tests/server/server-live-close.test.ts new file mode 100644 index 00000000000..ec6b2612a24 --- /dev/null +++ b/tests/server/server-live-close.test.ts @@ -0,0 +1,188 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import type { ServerWebSocket } from "bun"; +import { createHash } from "node:crypto"; +import { createServer, type AddressInfo, type Socket } from "node:net"; +import { + attachLiveSidebandUpstream, + closeLiveSideband, + closeLiveSidebandBeforeUpgrade, +} from "../../src/server/index/live-sideband"; +import type { WsData } from "../../src/server/ws-bridge"; + +const proxyKeys = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"]; +let savedProxyEnv: Record; +beforeEach(() => { + savedProxyEnv = Object.fromEntries(proxyKeys.map(key => [key, process.env[key]])); + for (const key of proxyKeys) delete process.env[key]; +}); +afterEach(() => { + for (const key of proxyKeys) { + if (savedProxyEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedProxyEnv[key]; + } +}); + +type Phase = "before upgrade" | "attached"; +type TerminableSocket = WebSocket & { terminate(): void }; + +function closingOwner(phase: Phase, upstream: WebSocket) { + const releasedStates: number[] = []; + let finishes = 0; + const released = Promise.withResolvers(); + const release = () => { releasedStates.push(upstream.readyState); released.resolve(); }; + const data: WsData = { + kind: "live-sideband", liveUpstream: upstream, liveOpened: true, + liveUpstreamHandoff: { failure: () => undefined, take: () => ({ ok: true, frames: [] }) }, + liveTurnAdmissionLease: { release } as WsData["liveTurnAdmissionLease"], + liveFinish: () => { finishes++; }, + }; + const downstream = { data, readyState: WebSocket.OPEN, close() {}, send: () => 1 } as unknown as ServerWebSocket; + if (phase === "attached") attachLiveSidebandUpstream(downstream); + return { + data, releasedStates, finishes: () => finishes, + close() { + if (phase === "attached") closeLiveSideband(downstream, 1001, "client finished"); + else closeLiveSidebandBeforeUpgrade(upstream, release, 1001, "client finished"); + }, + async waitForRelease() { + let timer: ReturnType | undefined; + try { + await Promise.race([ + released.promise, + new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("sideband lease remained held")), 3_000); }), + ]); + } finally { clearTimeout(timer); } + }, + }; +} + +/** A real TCP peer that optionally acknowledges Close; Bun.serve always acknowledges it. */ +async function withClosePeer(answerClose: boolean, run: (fixture: { + upstream: TerminableSocket; + closes: Array<{ code: number; reason: string }>; + terminations(): number; + peerClosed: Promise; +}) => Promise) { + const peers = new Set(); + const closes: Array<{ code: number; reason: string }> = []; + const peerClosed = Promise.withResolvers(); + const server = createServer(peer => { + peers.add(peer); + peer.on("error", () => {}); + peer.on("close", () => { peers.delete(peer); peerClosed.resolve(); }); + let handshake = ""; + let opened = false; + let frames = Buffer.alloc(0); + peer.on("data", chunk => { + if (!opened) { + handshake += chunk.toString(); + if (!handshake.includes("\r\n\r\n")) return; + const key = /sec-websocket-key:\s*([^\r\n]+)/i.exec(handshake)?.[1]; + const accept = createHash("sha1").update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`).digest("base64"); + opened = true; + peer.write("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" + + `Sec-WebSocket-Accept: ${accept}\r\n\r\n`); + return; + } + frames = Buffer.concat([frames, chunk]); + if (frames.length < 2) return; + const length = frames[1]! & 0x7f; + const masked = (frames[1]! & 0x80) !== 0; + const offset = masked ? 6 : 2; + // The fixture only receives a single, short Close control frame. + if ((frames[0]! & 0x0f) !== 8 || length > 125 || frames.length < offset + length) return; + const payload = Buffer.from(frames.subarray(offset, offset + length)); + if (masked) for (let i = 0; i < payload.length; i++) payload[i] = payload[i]! ^ frames[2 + i % 4]!; + closes.push({ code: payload.readUInt16BE(0), reason: payload.subarray(2).toString() }); + frames = Buffer.alloc(0); + if (answerClose) peer.end(Buffer.concat([Buffer.from([0x88, payload.length]), payload])); + }); + }); + let upstream: TerminableSocket | undefined; + try { + await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); }); + upstream = new WebSocket(`ws://127.0.0.1:${(server.address() as AddressInfo).port}`) as TerminableSocket; + let terminations = 0; + const terminate = upstream.terminate.bind(upstream); + upstream.terminate = () => { terminations++; terminate(); }; + await new Promise((resolve, reject) => { + upstream!.addEventListener("open", () => resolve(), { once: true }); + upstream!.addEventListener("error", () => reject(new Error("fixture websocket failed to open")), { once: true }); + }); + await run({ upstream, closes, terminations: () => terminations, peerClosed: peerClosed.promise }); + } finally { + upstream?.terminate(); + for (const peer of peers) peer.destroy(); + await new Promise(resolve => server.close(() => resolve())); + } +} + +describe.each(["before upgrade", "attached"] as const)("live sideband close %s", phase => { + // The pinned 1.4.3 runner reports close before the peer responds. Exercise this + // transport regression under the bundled runtime as well as the ordinary suite. + test.skipIf(!Bun.semver.satisfies(Bun.version, ">=1.4.4"))("terminates a silent peer before releasing its lease", async () => { + await withClosePeer(false, async fixture => { + const owner = closingOwner(phase, fixture.upstream); + owner.close(); + expect(owner.releasedStates).toEqual([]); + expect(fixture.upstream.readyState).toBe(WebSocket.CLOSING); + await owner.waitForRelease(); + await fixture.peerClosed; + expect(fixture.terminations()).toBe(1); + expect(fixture.closes).toEqual([{ code: 1001, reason: "client finished" }]); + expect(owner.releasedStates).toEqual([WebSocket.CLOSED]); + if (phase === "attached") { + expect(owner.data.liveUpstream).toBeUndefined(); + expect(owner.data.liveCloseFallback).toBeUndefined(); + expect(owner.finishes()).toBe(1); + } + }); + }); + + test("preserves a graceful close and cancels forced termination", async () => { + await withClosePeer(true, async fixture => { + const owner = closingOwner(phase, fixture.upstream); + owner.close(); + await owner.waitForRelease(); + await fixture.peerClosed; + await Bun.sleep(1_100); + expect(fixture.closes).toEqual([{ code: 1001, reason: "client finished" }]); + expect(fixture.terminations()).toBe(0); + expect(owner.releasedStates).toEqual([WebSocket.CLOSED]); + }); + }); +}); + +test.each(["missing", "throws", "still closing", "closed"] as const)("termination %s preserves actual-close ownership", async mode => { + class Upstream extends EventTarget { + readyState: number = WebSocket.OPEN; + closes = 0; + terminations = 0; + terminate?: () => void; + close() { this.closes++; this.readyState = WebSocket.CLOSING; } + finish() { this.readyState = WebSocket.CLOSED; this.dispatchEvent(new CloseEvent("close", { code: 1000 })); } + } + const cases = (["before upgrade", "attached"] as const).map(phase => { + const upstream = new Upstream(); + if (mode !== "missing") upstream.terminate = () => { + upstream.terminations++; + if (mode === "throws") throw new Error("fixture termination failed"); + if (mode === "closed") upstream.readyState = WebSocket.CLOSED; + }; + const owner = closingOwner(phase, upstream as unknown as WebSocket); + owner.close(); + expect(owner.releasedStates).toEqual([]); + return { upstream, owner }; + }); + try { + await Bun.sleep(1_100); + for (const { upstream, owner } of cases) { + expect(upstream.closes).toBe(mode === "missing" ? 2 : 1); + expect(upstream.terminations).toBe(mode === "missing" ? 0 : 1); + expect(owner.releasedStates).toEqual(mode === "closed" ? [WebSocket.CLOSED] : []); + upstream.finish(); + upstream.finish(); + expect(owner.releasedStates).toEqual([WebSocket.CLOSED]); + } + } finally { for (const { upstream } of cases) upstream.finish(); } +}); diff --git a/tests/server/server-live.test.ts b/tests/server/server-live.test.ts index 28c68c04a1b..cc95a52e006 100644 --- a/tests/server/server-live.test.ts +++ b/tests/server/server-live.test.ts @@ -22,6 +22,7 @@ import { MAX_WS_FRAME_BYTES, openLiveSidebandUpstream, startServer, + type StartServerDeps, } from "../../src/server"; import { activeRegistryMetrics, @@ -32,6 +33,7 @@ import { import type { OcxConfig } from "../../src/types"; import { fakeChatGptJwt } from "../helpers/fake-chatgpt-jwt"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { ownedServiceHomeInspection } from "../helpers/owned-service-home-inspection"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { phaseTimer } from "../helpers/phase-timing"; import { expectSidebandUpgrade, openSidebandClient, redirectSidebandWebSocket, sidebandRelayUpstream } from "../helpers/sideband-relay-probe"; @@ -149,6 +151,15 @@ function forwardConfig(): OcxConfig { } as OcxConfig; } +// Isolate relay/readiness fixtures from host service-manager probes. +// Explicit per-case dependencies override this default. +function startTestServer(port?: number, deps: StartServerDeps = {}): ReturnType { + return startServer(port, { + inspectNativeCodexOwnership: ownedServiceHomeInspection("server-live fixture"), + ...deps, + }); +} + function expectReadyProtocolMetadata(body: Record, managementUrl: string): void { expect(body).toMatchObject({ protocol: 1, @@ -192,7 +203,7 @@ test("POST /v1/live rewrites ChatGPT multipart into backend realtime/calls JSON" config.providers.openai!.baseUrl = "https://chatgpt.com/backend-api/codex///"; saveConfig(config); - const server = startServer(0); + const server = startTestServer(0); try { const { body, contentType } = multipartLiveBody("v=0-offer", { model: "gpt-live", instructions: "hi" }); const response = await fetch(new URL("/v1/live", server.url), { @@ -241,7 +252,7 @@ test.each(["/v1/live", "/backend-api/codex/live"])("POST %s relays to an OpenAI }, } as OcxConfig); - const server = startServer(0); + const server = startTestServer(0); try { const { body, contentType } = multipartLiveBody(); const response = await fetch(new URL(path, server.url), { @@ -272,7 +283,7 @@ test("call-create forwards Frameless protocol headers and keeps auth pool-owned" const upstream = fakeLiveUpstream(captured); saveConfig(forwardConfig()); - const server = startServer(0); + const server = startTestServer(0); try { const { body, contentType } = multipartLiveBody("v=0-offer", { model: "gpt-live", instructions: "hi" }); const response = await fetch(new URL("/v1/live", server.url), { @@ -320,7 +331,7 @@ test("call-create without protocol headers does not invent them upstream", async const upstream = fakeLiveUpstream(captured); saveConfig(forwardConfig()); - const server = startServer(0); + const server = startTestServer(0); try { const { body, contentType } = multipartLiveBody("v=0-offer", { model: "gpt-live", instructions: "hi" }); const response = await fetch(new URL("/v1/live", server.url), { @@ -353,7 +364,7 @@ test("POST /v1/realtime/calls is accepted and relays like /v1/live", async () => const upstream = fakeLiveUpstream(captured, 201, "/v1/realtime/calls/rtc_codex"); saveConfig(forwardConfig()); - const server = startServer(0); + const server = startTestServer(0); try { const { body, contentType } = multipartLiveBody("v=0-codex"); const response = await fetch(new URL("/v1/realtime/calls", server.url), { @@ -384,7 +395,7 @@ test("ChatGPT multipart rewrite allows SDP-only offers without session", async ( const upstream = fakeLiveUpstream(captured); saveConfig(forwardConfig()); - const server = startServer(0); + const server = startTestServer(0); try { const { body, contentType } = multipartLiveBody("v=0-sdp-only", null); const response = await fetch(new URL("/v1/live", server.url), { @@ -407,7 +418,7 @@ test("ChatGPT multipart rewrite allows SDP-only offers without session", async ( test("OPTIONS preflight allows ChatGPT-Account-Id for voice clients", async () => { saveConfig(forwardConfig()); - const server = startServer(0); + const server = startTestServer(0); try { const response = await fetch(new URL("/v1/live", server.url), { method: "OPTIONS", @@ -440,7 +451,7 @@ test("POST /v1/live without an OpenAI upstream returns 400", async () => { }, } as OcxConfig); - const server = startServer(0); + const server = startTestServer(0); try { const response = await fetch(new URL("/v1/live", server.url), { method: "POST", @@ -457,7 +468,7 @@ test("POST /v1/live without an OpenAI upstream returns 400", async () => { test("GET /v1/live still hits the unknown-endpoint JSON 404 guard", async () => { saveConfig(forwardConfig()); - const server = startServer(0); + const server = startTestServer(0); try { const response = await fetch(new URL("/v1/live", server.url), { method: "GET", @@ -500,7 +511,7 @@ test("a routed pool account's token overrides the caller bearer on the live rela chatgptAccountId: "acct-pool-a", }); - const server = startServer(0); + const server = startTestServer(0); try { const { body, contentType } = multipartLiveBody(); const response = await fetch(new URL("/v1/live", server.url), { @@ -579,7 +590,7 @@ test("call-create and its sideband join bind to the same pool account (openai/co } } as typeof WebSocket; - const server = startServer(0); + const server = startTestServer(0); try { const voiceHeaders = { authorization: `Bearer ${DIRECT_CHATGPT_TOKEN}`, @@ -671,7 +682,7 @@ test("sideband GET /v1/live/{callId} relays the exact frame ceiling bidirectiona // helper because this case is at its size cap and the repo answer is not to compress. const { OriginalWebSocket, restore } = redirectSidebandWebSocket(upstream.port); restoreWebSocket = restore; - const server = live = startServer(0); + const server = live = startTestServer(0); const ws = openSidebandClient(OriginalWebSocket, server.url, "/v1/live/rtc_sideband", DIRECT_CHATGPT_TOKEN); client = ws; // Each leg is its own segment and the timer's ticks read the peer's event count, so a @@ -764,7 +775,7 @@ test("standalone GET /v1/realtime?intent=quicksilver&model= upgrades and relays } } as typeof WebSocket; - const server = startServer(0); + const server = startTestServer(0); try { const wsUrl = new URL(`/v1/realtime?intent=quicksilver&model=gpt-realtime-1.5&access_token=dropme`, server.url); wsUrl.protocol = "ws:"; @@ -844,7 +855,7 @@ test("standalone GET /v1/live?model= upgrades and relays bidirectionally", async } } as typeof WebSocket; - const server = startServer(0); + const server = startTestServer(0); try { const wsUrl = new URL(`/v1/live?model=gpt-realtime-1.5`, server.url); wsUrl.protocol = "ws:"; @@ -888,7 +899,7 @@ test("standalone realtime upgrade keeps the auth and origin guards", async () => saveConfig(forwardConfig()); process.env.OPENCODEX_API_AUTH_TOKEN = "required-secret"; - const server = startServer(0); + const server = startTestServer(0); try { const upgradeHeaders = { connection: "upgrade", @@ -1211,7 +1222,7 @@ test("sideband relay preserves multibyte UTF-8 frames byte-identically in both d } } as typeof WebSocket; - const server = startServer(0); + const server = startTestServer(0); try { const wsUrl = new URL(`/v1/live/rtc_korean`, server.url); wsUrl.protocol = "ws:"; @@ -1315,7 +1326,7 @@ test("sideband frame log preserves delivery without recording damaged or clean t } } as typeof WebSocket; - const server = startServer(0); + const server = startTestServer(0); try { const wsUrl = new URL(`/v1/live/rtc_framelog`, server.url); wsUrl.protocol = "ws:"; @@ -1396,7 +1407,7 @@ describe("GET /readyz", () => { for (const { outcome, expectedStatus, expectedHttp } of cases) { const gate = createReadinessGate(); - const server = startServer(0, { readinessGate: gate }); + const server = startTestServer(0, { readinessGate: gate }); try { const pending = await fetch(new URL("/readyz", server.url)); expect(pending.status).toBe(503); @@ -1420,7 +1431,7 @@ describe("GET /readyz", () => { test("fresh gate is pending; /healthz 200 while /readyz 503 with Retry-After", async () => { saveConfig(forwardConfig()); const gate = createReadinessGate(); - const server = startServer(0, { readinessGate: gate }); + const server = startTestServer(0, { readinessGate: gate }); try { const base = server.url; // Liveness is immediate and unchanged. @@ -1459,7 +1470,7 @@ describe("GET /readyz", () => { runtimeRole: "hub", hub: { managementPublicOrigin: "https://hub.example.test" }, }); - const server = startServer(0); + const server = startTestServer(0); try { const ready = await fetch(new URL("/readyz", server.url), { headers: { @@ -1484,7 +1495,7 @@ describe("GET /readyz", () => { test("/readyz is 200 with status ready only after gate.markReady()", async () => { saveConfig(forwardConfig()); const gate = createReadinessGate(); - const server = startServer(0, { readinessGate: gate }); + const server = startTestServer(0, { readinessGate: gate }); try { const base = server.url; expect(((await (await fetch(new URL("/readyz", base))).json()) as { status: string }).status).toBe("pending"); @@ -1504,7 +1515,7 @@ describe("GET /readyz", () => { saveConfig(forwardConfig()); const gate = createReadinessGate(); gate.markFailed(); - const server = startServer(0, { readinessGate: gate }); + const server = startTestServer(0, { readinessGate: gate }); try { const res = await fetch(new URL("/readyz", server.url)); expect(res.status).toBe(503); @@ -1518,7 +1529,7 @@ describe("GET /readyz", () => { test("exact method/path: POST /readyz and GET /readyz/ are NOT matched", async () => { saveConfig(forwardConfig()); const gate = createReadinessGate(); - const server = startServer(0, { readinessGate: gate }); + const server = startTestServer(0, { readinessGate: gate }); try { const base = server.url; // POST must not be accepted as readiness (falls through to the JSON 404 guard). @@ -1560,7 +1571,7 @@ describe("GET /readyz", () => { test("/readyz answers without an admission token (unauthenticated, like /healthz)", async () => { saveConfig(forwardConfig()); const gate = createReadinessGate(); - const server = startServer(0, { readinessGate: gate }); + const server = startTestServer(0, { readinessGate: gate }); try { // No Authorization header, no API auth token in env (beforeEach deletes it). const res = await fetch(new URL("/readyz", server.url), { headers: { Authorization: "" } }); @@ -1576,7 +1587,7 @@ describe("GET /readyz", () => { test("ephemeral startServer(0): /readyz reports the actual bound port (not the requested 0)", async () => { saveConfig(forwardConfig()); const gate = createReadinessGate(); - const server = startServer(0, { readinessGate: gate }); + const server = startTestServer(0, { readinessGate: gate }); try { const boundPort = new URL(server.url).port; // The bound port is a real ephemeral port, not the requested 0. @@ -1601,8 +1612,8 @@ describe("per-server readiness gate isolation", () => { saveConfig(forwardConfig()); const gateA: ReadinessGate = createReadinessGate(); const gateB: ReadinessGate = createReadinessGate(); - const serverA = startServer(0, { readinessGate: gateA }); - const serverB = startServer(0, { readinessGate: gateB }); + const serverA = startTestServer(0, { readinessGate: gateA }); + const serverB = startTestServer(0, { readinessGate: gateB }); try { gateA.markReady(); // gateB stays pending. @@ -1624,8 +1635,8 @@ describe("per-server readiness gate isolation", () => { saveConfig(forwardConfig()); const gateA: ReadinessGate = createReadinessGate(); const gateB: ReadinessGate = createReadinessGate(); - const serverA = startServer(0, { readinessGate: gateA }); - const serverB = startServer(0, { readinessGate: gateB }); + const serverA = startTestServer(0, { readinessGate: gateA }); + const serverB = startTestServer(0, { readinessGate: gateB }); try { gateA.markReady(); gateB.markFailed(); @@ -1646,7 +1657,7 @@ describe("per-server readiness gate isolation", () => { test("a server with no gate passed in still serves pending /readyz (fresh private gate)", async () => { saveConfig(forwardConfig()); // No gate supplied → startServer creates a fresh pending private gate. - const server = startServer(0); + const server = startTestServer(0); try { const res = await fetch(new URL("/readyz", server.url)); expect(res.status).toBe(503); @@ -1661,7 +1672,7 @@ describe("per-server readiness gate isolation", () => { const gateA: ReadinessGate = createReadinessGate(); const gateB: ReadinessGate = createReadinessGate(); // Ephemeral start: A binds a real kernel-assigned port (NOT a fixed user port). - const serverA = startServer(0, { readinessGate: gateA }); + const serverA = startTestServer(0, { readinessGate: gateA }); try { gateA.markReady(); const occupiedPort = Number(new URL(serverA.url).port); @@ -1672,7 +1683,7 @@ describe("per-server readiness gate isolation", () => { // Binding the occupied port with a separate gate must throw (EADDRINUSE). // It must never silently share A's listener or reset A's gate. - expect(() => startServer(occupiedPort, { readinessGate: gateB })).toThrow(); + expect(() => startTestServer(occupiedPort, { readinessGate: gateB })).toThrow(); // A is unaffected by the failed bind: still HTTP 200 / ready. const resA = await fetch(new URL("/readyz", serverA.url)); @@ -1702,7 +1713,7 @@ describe("GET /readyz while draining", () => { test("a ready gate reports pending (503) once the listener starts draining", async () => { saveConfig(forwardConfig()); const gate = createReadinessGate(); - const server = startServer(0, { readinessGate: gate }); + const server = startTestServer(0, { readinessGate: gate }); try { gate.markReady(); const base = server.url; @@ -2024,7 +2035,7 @@ test("a failed pre-upgrade handshake retains admission until its CLOSING upstrea saveConfig(forwardConfig()); const upstream = new FakeUpstreamSocket(); upstream.closeMode = "closing"; - const server = startServer(0, { + const server = startTestServer(0, { liveSidebandWebSocketFactory: () => { queueMicrotask(() => upstream.emit("error", {})); return upstream as unknown as WebSocket; @@ -2068,7 +2079,7 @@ test("a failed pre-upgrade handoff retains admission until its CLOSING upstream saveConfig(forwardConfig()); const upstream = new FakeUpstreamSocket(); upstream.closeMode = "closing"; - const server = startServer(0, { + const server = startTestServer(0, { liveSidebandWebSocketFactory: () => { queueMicrotask(() => { upstream.emit("open", {}); @@ -2141,7 +2152,7 @@ test("a sideband join whose upstream handshake fails never opens the client sock } } as typeof WebSocket; - const server = startServer(0); + const server = startTestServer(0); const activeTurnsBefore = activeRegistryMetrics().activeTurns.active; try { const wsUrl = new URL(`/v1/realtime?call_id=rtc_dead_call`, server.url); @@ -2190,7 +2201,7 @@ test("a sideband join whose upstream handshake fails never opens the client sock test("an upstream that opens then closes before relay attachment refuses the client and releases admission", async () => { saveConfig(forwardConfig()); const upstream = new FakeUpstreamSocket(); - const server = startServer(0, { + const server = startTestServer(0, { liveSidebandWebSocketFactory: () => { queueMicrotask(() => { upstream.emit("open", {}); From 5c588f297aa9793112c9cf77a5850c4c8a445a36 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 26 Oct 2026 20:50:18 +0900 Subject: [PATCH 085/101] fix(runtime): bypass proxy environment for local stop requests (#7754) Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> Co-authored-by: lidge-jun --- scripts/test-layout/layout.json | 2 +- src/lib/process-control.ts | 14 ++- src/server/direct-local-http.ts | 7 +- structure/runtime.md | 4 +- tests/fixtures/test-layout-expected.json | 1 + tests/service/stop-direct-fetch.test.ts | 131 +++++++++++++++++++++++ 6 files changed, 153 insertions(+), 6 deletions(-) create mode 100644 tests/service/stop-direct-fetch.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 29e99444034..32ffbd92f55 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1777,7 +1777,7 @@ "startup-prompt.test.ts": "server", "state-store-sweeper.test.ts": "oauth", "stepfun-provider.test.ts": "providers", - "stop-deferred-teardown.test.ts": "service", + "stop-deferred-teardown.test.ts": "service", "stop-direct-fetch.test.ts": "service", "storage-cleanup.test.ts": "storage", "storage-mutation-race.test.ts": "storage", "storage-policy-config-race.test.ts": "storage", diff --git a/src/lib/process-control.ts b/src/lib/process-control.ts index 34cbd90f58b..7b791e4915e 100644 --- a/src/lib/process-control.ts +++ b/src/lib/process-control.ts @@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process"; import { isIP } from "node:net"; import { loadConfig } from "../config"; import { readRuntimePort } from "../config/process-state"; +import { directLocalHttpFetch } from "../server/direct-local-http"; import { configuredAdminToken } from "./admin-secrets"; export function isProcessAlive(pid: number): boolean { @@ -203,6 +204,17 @@ export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}): */ type StopRefusal = { message: string | null; code: string | null }; +async function stopRequestFetch(input: string | URL | Request, init?: RequestInit): Promise { + let status: number | undefined; + try { + return await directLocalHttpFetch(input, init, { onResponseStatus: value => { status = value; } }); + } catch (error) { + // A received refusal forbids forced stop even if its explanatory body fails. + if (status === 409) return new Response(null, { status }); + throw error; + } +} + async function stopProxyGracefullyDetailed( pid: number, io: GracefulStopIo = {}, @@ -217,7 +229,7 @@ async function stopProxyGracefullyDetailed( const headers: Record = {}; const token = configuredAdminToken(env.OPENCODEX_HOME?.trim() || undefined, env as NodeJS.ProcessEnv); if (token) headers["x-opencodex-api-key"] = token; - const fetchFn = io.fetchFn ?? fetch; + const fetchFn = io.fetchFn ?? stopRequestFetch; let sharedTeardownConfirmed = false; try { // `ocx stop` asks the proxy NOT to restore shared client config: it does that itself, diff --git a/src/server/direct-local-http.ts b/src/server/direct-local-http.ts index f3891ebf109..ab3e1438c4e 100644 --- a/src/server/direct-local-http.ts +++ b/src/server/direct-local-http.ts @@ -7,6 +7,8 @@ type DirectLocalHttpIo = { timeoutMs?: number; connect?: (hostname: string, port: number) => Socket; scheduleDeadline?: (onTimeout: () => void, timeoutMs: number) => () => void; + /** Observe a parsed response status before reading its bounded body. */ + onResponseStatus?: (status: number) => void; }; function abortReason(signal: AbortSignal): Error { @@ -96,7 +98,7 @@ function parseResponseHead(bytes: Buffer, boundary: number): { return { status, statusText: match[2] ?? "", headers }; } -function advanceResponseFraming(bytes: Buffer, initial: ResponseFraming): ResponseFraming { +function advanceResponseFraming(bytes: Buffer, initial: ResponseFraming, onResponseStatus?: (status: number) => void): ResponseFraming { let framing = initial; for (;;) { if (framing.kind === "complete" || framing.kind === "close") return framing; @@ -107,6 +109,7 @@ function advanceResponseFraming(bytes: Buffer, initial: ResponseFraming): Respon return { kind: "head", searchFrom: Math.max(0, bytes.length - 3) }; } const { status, headers } = parseResponseHead(bytes, boundary); + onResponseStatus?.(status); const bodyStart = boundary + 4; if (status === 204 || status === 205 || status === 304) { if (bytes.length > bodyStart) { @@ -350,7 +353,7 @@ export async function directLocalHttpFetch( } bytes.copy(responseBytes, receivedBytes - bytes.byteLength); try { - framing = advanceResponseFraming(responseBytes.subarray(0, receivedBytes), framing); + framing = advanceResponseFraming(responseBytes.subarray(0, receivedBytes), framing, io.onResponseStatus); if (framing.kind === "complete") finish(); } catch (error) { finish(error instanceof Error ? error : new Error(String(error))); diff --git a/structure/runtime.md b/structure/runtime.md index 9c1157d9b4b..ebeac303a54 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -335,8 +335,8 @@ field for every tool. Only bare or `default.`-prefixed `exec` and `apply_patch` one recognized alternate body field or remove one complete outer Markdown fence; ambiguous alternate fields and every other freeform grammar pass through unchanged. -The server exposes read-only authenticated `GET /api/stop` with `schema: "ocx-stop-capabilities/1"`, PID and connected-client preservation for API, signal and manager shutdown. `src/cli/stop-restore.ts` requires matching client-role health identity or this capability before any connected-client service stop, then rechecks the pinned target before POST/kill. Its probes and connected-stop POST use the proxy-independent TCP transport in `src/server/direct-local-http.ts`, a shared two-second deadline and a 4 KiB capability-document limit; unknown targets refuse without mutation. A stopped connected client skips manager mutation only after the manager is observed inactive and all known local endpoints are definitively dead; the ordinary receipt and environment cleanup still runs. `POST /api/stop`: `src/server/stop-teardown.ts` checks sibling then validated persisted connected-client ownership before receipt deferral or restoration. Both owners answer `sharedTeardown: "not-owned"` without restoring Codex/catalog or stripping Grok; an existing receipt remains outstanding. Other owners restore native Codex/Grok, or defer only with the matching receipt. The route stops any installed service -(to prevent respawn), and exits the process. The GUI sidebar stop button calls this endpoint. +The server exposes read-only authenticated `GET /api/stop` with `schema: "ocx-stop-capabilities/1"`, PID and connected-client preservation for API, signal and manager shutdown. `src/cli/stop-restore.ts` requires matching client-role health identity or this capability before any connected-client service stop, then rechecks the pinned target before POST/kill. Its probes use the proxy-independent TCP transport in `src/server/direct-local-http.ts`, a shared two-second deadline and a 4 KiB capability-document limit; unknown targets refuse without mutation. `src/lib/process-control.ts` defaults every local runtime stop POST to that direct transport, ignoring HTTP(S)/ALL proxy environment variables for CLI stop, service cleanup and uninstall; its injected `fetchFn` takes precedence. A stopped connected client skips manager mutation only after the manager is observed inactive and all known local endpoints are definitively dead; the ordinary receipt and environment cleanup still runs. `POST /api/stop`: `src/server/stop-teardown.ts` checks sibling then validated persisted connected-client ownership before receipt deferral or restoration. Both owners answer `sharedTeardown: "not-owned"` without restoring Codex/catalog or stripping Grok; an existing receipt remains outstanding. Other owners restore native Codex/Grok, or defer only with the matching receipt. The route stops any installed service +(to prevent respawn), and exits the process. The GUI sidebar stop button calls this endpoint. An observed 409 remains a stop refusal even when its response body is truncated or times out; the direct transport exposes the parsed status before body completion so the stop helper retains that decision. > Decision record: [ADR-0004](decisions/ADR-0004-lifecycle.md) diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 98c3b91b81e..f938b58673d 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1235,6 +1235,7 @@ "startup-action-control.test.ts": "server", "login-shell-path.test.ts": "server", "startup-health-ui.test.ts": "gui", "startup-prompt.test.ts": "server", "state-store-sweeper.test.ts": "oauth", "stepfun-provider.test.ts": "providers", "stop-deferred-teardown.test.ts": "service", + "stop-direct-fetch.test.ts": "service", "storage-cleanup.test.ts": "storage", "storage-mutation-race.test.ts": "storage", "storage-policy-config-race.test.ts": "storage", "storage-policy-job-responsive.test.ts": "storage", "storage-policy.test.ts": "storage", "storage-restore-job-errors.test.ts": "storage", diff --git a/tests/service/stop-direct-fetch.test.ts b/tests/service/stop-direct-fetch.test.ts new file mode 100644 index 00000000000..f4cabe14271 --- /dev/null +++ b/tests/service/stop-direct-fetch.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { createServer, type Socket } from "node:net"; +import { stopProxyGracefully } from "../../src/lib/process-control"; +import { repoPath } from "../helpers/repo-root"; + +const stopRunner = ` + const { stopProxyGracefully } = await import("./src/lib/process-control.ts"); + const port = Number(process.env.OCX_TEST_STOP_PORT); + const origin = "http://127.0.0.1:" + port; + const control = await fetch(origin + "/__proxy-control", { + signal: AbortSignal.timeout(2000), + }).then(response => response.json()); + const result = await stopProxyGracefully(process.pid, { + runtimeEndpoint: { hostname: "127.0.0.1", port }, + waitExit: () => true, + exitTimeoutMs: 2000, + deferSharedTeardownNonce: process.env.OCX_TEST_STOP_NONCE || undefined, + env: { OPENCODEX_ADMIN_AUTH_TOKEN: "ocx-test-direct-stop-token" }, + }); + console.log(JSON.stringify({ control: control.via, result })); +`; + +describe("local stop transport", () => { + for (const [status, stalled] of [[409, false], [409, true], [401, false]] as const) { + test(`${status} with a ${stalled ? "stalled" : "truncated"} body retains stop semantics`, async () => { + const sockets = new Set(); + const server = createServer(socket => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + socket.once("data", () => { + socket.write(`HTTP/1.1 ${status} Response\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{`); + if (!stalled) socket.end(); + }); + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + let waits = 0; + try { + const address = server.address(); + if (!address || typeof address === "string") throw new Error("missing TCP listener"); + const result = await stopProxyGracefully(4242, { + runtimeEndpoint: { hostname: "127.0.0.1", port: address.port }, + waitExit: () => { waits++; return true; }, env: {}, exitTimeoutMs: 100, + }); + expect(result).toBe(status === 409 ? "refused" : false); + expect(waits).toBe(0); + } finally { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + } + }); + } + + for (const deferred of [false, true]) { + test(`default ${deferred ? "deferred" : "plain"} stop bypasses proxy environment`, async () => { + const targetRequests: { method: string; path: string; token: string | null }[] = []; + const proxyRequests: typeof targetRequests = []; + let controls = 0; + const reply = () => Response.json({ success: true, sharedTeardown: deferred ? "deferred" : "performed" }); + const record = (request: Request) => ({ method: request.method, + path: new URL(request.url).pathname + new URL(request.url).search, + token: request.headers.get("x-opencodex-api-key") }); + const target = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch(request) { + targetRequests.push(record(request)); + return reply(); + } }); + const proxy = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch(request) { + if (new URL(request.url).pathname === "/__proxy-control") { + controls++; + return Response.json({ via: "proxy" }); + } + proxyRequests.push(record(request)); + return reply(); + } }); + let child: ReturnType | undefined; + let watchdog: ReturnType | undefined; + try { + // Child-only environment: Bun reads proxy settings at startup. + const env = { ...process.env, OCX_TEST_STOP_PORT: String(target.port), + OCX_TEST_STOP_NONCE: deferred ? "dummy-receipt" : "", + NO_PROXY: "example.invalid", no_proxy: "example.invalid" }; + for (const key of ["HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy"]) { + env[key] = `http://127.0.0.1:${proxy.port}`; + } + child = Bun.spawn([process.execPath, "--eval", stopRunner], { + cwd: repoPath(), env, stdin: "ignore", stdout: "pipe", stderr: "pipe", + }); + watchdog = setTimeout(() => child?.kill(), 8000); + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, new Response(child.stdout).text(), new Response(child.stderr).text(), + ]); + expect(exitCode, stderr).toBe(0); + expect(JSON.parse(stdout)).toEqual({ control: "proxy", result: true }); + expect(controls).toBe(1); + expect({ targetRequests, proxyRequests }).toEqual({ + targetRequests: [{ method: "POST", path: "/api/stop" + (deferred + ? "?deferSharedTeardown=1&teardownNonce=dummy-receipt" : ""), token: "ocx-test-direct-stop-token" }], + proxyRequests: [], + }); + } finally { + if (watchdog) clearTimeout(watchdog); + if (child && child.exitCode === null) { child.kill(); await child.exited; } + await target.stop(true); + await proxy.stop(true); + } + }); + } + + // These paths mutate service/process state; exercise their shared real transport above + // and verify each caller delegates without overriding it with the global fetch. + for (const [file, functionName, count] of [ + ["src/cli/index.ts", "handleStopUnlocked", 1], + ["src/service/orchestration.ts", "stopTrackedProxyIfRunning", 2], + ["src/cli/index.ts", "handleUninstall", 2], + ] as const) { + test(`${functionName} retains the direct stop default`, () => { + const source = readFileSync(repoPath(file), "utf8"); + const start = source.indexOf(`async function ${functionName}(`); + expect(start).toBeGreaterThanOrEqual(0); + const end = source.indexOf("\n}", start); + expect(end).toBeGreaterThan(start); + const calls = [...source.slice(start, end).matchAll(/await stopProxy\(([\s\S]*?)\);/g)]; + expect(calls).toHaveLength(count); + for (const call of calls) { + // A type assertion is not a value override; the connected-client direct + // transport is permitted while every other call uses the shared default. + expect(call[1]!.replace(/as typeof fetch\b/g, "")).not.toMatch(/\bfetch\b/); + } + }); + } +}); From 9d155272a21f6f0d42d164f711efe6f366bcb582 Mon Sep 17 00:00:00 2001 From: Transient Onlooker Date: Mon, 26 Oct 2026 20:50:53 +0900 Subject: [PATCH 086/101] feat(codex): expose opt-in Flex tier for eligible routed models (#7534) * fix(codex): revoke retained Flex tiers on policy withdrawal * docs(catalog): document retained Flex policy revalidation * fix(structure): restore catalog documentation within size budget * fix(codex): reject combo Flex during degraded catalog retention * fix(codex): decode retained model adapter overrides before Flex policy * test(codex): expect the Flex capability on the xAI hint projection CI shard 2/4 failed because the exact-object assertion for grok-4.6's provider hint output predates the new supportsFlexTier field (false for that model). --------- Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> --- .../content/docs/guides/codex-app-models.md | 39 +++++++++++ .../docs/ko/guides/codex-app-models.md | 40 +++++++++++ src/codex/catalog/build-entries.ts | 22 ++++++ src/codex/catalog/configured-tiers.ts | 70 +++++++++++++++++++ src/codex/catalog/effort.ts | 10 ++- src/codex/catalog/model-hints.ts | 4 +- src/codex/catalog/parsing.ts | 2 + src/codex/catalog/retained-sync.ts | 3 +- src/codex/catalog/routed-gather.ts | 4 +- src/codex/convergence.ts | 3 +- tests/codex-integration/codex-catalog.test.ts | 2 +- .../configured-ultrafast-picker.test.ts | 61 +++++++++++++++- .../ultrafast-live-catalog.test.ts | 47 +++++++++++++ tests/routing/fastwire-policy.test.ts | 22 ++++++ 14 files changed, 321 insertions(+), 8 deletions(-) diff --git a/docs-site/src/content/docs/guides/codex-app-models.md b/docs-site/src/content/docs/guides/codex-app-models.md index a83c1e3e37b..2a04b4dcc19 100644 --- a/docs-site/src/content/docs/guides/codex-app-models.md +++ b/docs-site/src/content/docs/guides/codex-app-models.md @@ -320,6 +320,45 @@ advertised where it cannot be honored. An `openai-chat` destination can authoriz eligible model with `chatServiceTier: true`, or authorize only exact models with `modelSupportsServiceTier`; Responses routes do not need that extra Chat wire authorization. +### Selecting Flex in Codex + +For a routed provider and model verified to accept `service_tier: "flex"`, explicitly +declare Flex support alongside the existing Fast wire. Merge these fields into the +**existing** `providers.nanogpt` entry (do not replace its URL or credentials): + +```json +{ + "modelSupportsServiceTier": { "openai/gpt-6-luna": true }, + "fastWire": { + "kind": "service-tier", + "canonicalToWire": { "priority": "priority", "flex": "flex" }, + "foreignCallerTiers": "verbatim" + } +} +``` + +With an eligible `openai-responses` route, the Luna row advertises Standard, +Fast and Flex in Codex's speed selector. For an `openai-chat` route, also set +`chatServiceTier: true` **only if** the upstream Chat Completions endpoint supports +caller-provided service tiers. The `fastWire` mapping is **provider-wide**: +other service-tier-eligible routed models in the same `providers.nanogpt` +entry can also advertise Flex when the catalog publishes them as ordinary routed +rows, even when they are not listed in `modelSupportsServiceTier`. That map is not +an allowlist excluding other eligible models. Combo-namespace and native-alias +rows are **not** published with Flex. Verify Flex support for **every** eligible +ordinary routed model; +if only Luna supports Flex, configure it in a separate provider entry with +no other Fast-eligible models. Do not infer Flex support from Fast alone. + +Keep OpenCodex's global Fast mode on **Auto** (`fastMode` unset), as forcing +Fast or Default overrides the caller's Flex choice. After `ocx sync`, refresh the +Codex model list. Select **Flex** from the speed selector, or use +`/model --speed=flex` in Codex CLI. This is a **selectable tier**, +not a per-model forced default: switching models does not force Flex automatically. +The tier is forwarded as `service_tier: "flex"` and can still be declined by +the upstream; availability, cost and latency depend on the provider. Neither +the menu nor a request alone proves which tier was billed. + ### Selecting Ultra Fast in Codex Desktop For a provider that supports `service_tier: "ultrafast"`, declare that wire mapping once. diff --git a/docs-site/src/content/docs/ko/guides/codex-app-models.md b/docs-site/src/content/docs/ko/guides/codex-app-models.md index 899ffc19752..60b38ca4c9c 100644 --- a/docs-site/src/content/docs/ko/guides/codex-app-models.md +++ b/docs-site/src/content/docs/ko/guides/codex-app-models.md @@ -172,6 +172,46 @@ fast_mode = true `service_tier`가 제거됩니다(레지스트리가 정식 OpenAI를 `true`, DeepSeek과 Volcengine Ark를 `false`로 분류). 미분류 커스텀 게이트웨이는 호출자가 준 값을 그대로 보존하고 주입도 받지 않습니다. 따라서 처리 불가능한 곳에 fast 옵션이 노출되지 않으며, 커스텀 게이트웨이는 `true`로 명시적으로 옵트인할 수 있습니다. +### Codex에서 Flex 선택 + +`service_tier: "flex"` 지원이 확인된 라우팅 프로바이더와 모델에 한해 Flex를 +명시적으로 선언할 수 있습니다. 아래 필드는 **기존** `providers.nanogpt` 설정에 +병합해야 하며 URL과 API 키를 덮어쓰면 안 됩니다. + +```json +{ + "modelSupportsServiceTier": { "openai/gpt-6-luna": true }, + "fastWire": { + "kind": "service-tier", + "canonicalToWire": { "priority": "priority", "flex": "flex" }, + "foreignCallerTiers": "verbatim" + } +} +``` + +적격한 `openai-responses` 라우트에서는 Luna의 Codex 속도 선택기에 Standard, +Fast, Flex가 나타납니다. `openai-chat` 라우트는 Chat Completions API가 +호출자의 서비스 등급을 지원하는 것이 확인된 경우에만 `chatServiceTier: true`도 +설정해야 합니다. `fastWire` 매핑은 **프로바이더 전체에 적용**되므로 같은 +`providers.nanogpt` 항목에서 서비스 등급 사용이 이미 허용된 다른 모델에도 +Flex 선택지가 표시될 수 있습니다. `modelSupportsServiceTier`에 Luna만 적어도 +이미 적격인 다른 모델을 제외하는 허용 목록이 되는 것은 아닙니다. 다만 Flex 선택지는 +카탈로그에 게시되는 **일반 라우팅 모델 행**에만 표시되며, Combo 네임스페이스와 +Native Alias 행에는 표시되지 않습니다. 따라서 해당 항목에서 서비스 등급 사용이 +허용된 **모든 일반 라우팅 모델**의 Flex 지원 여부를 확인하세요. +Luna만 Flex를 지원한다면 Fast 지원 모델이 다른 모델과 섞이지 않도록 +**별도 프로바이더 항목**으로 분리해야 합니다. Fast 지원만으로 Flex 지원을 +추정해서는 안 됩니다. + +OpenCodex의 전역 Fast 설정은 **Auto**(`fastMode` 생략)로 유지하세요. +Fast 또는 Default 강제 모드는 Flex 선택을 덮어씁니다. `ocx sync` 후 Codex의 +모델 목록을 갱신하고 속도 선택기에서 **Flex**를 선택하거나, Codex CLI에서 +`/model --speed=flex`를 사용하면 됩니다. 이는 **선택 가능한 등급**이지 +모델별 강제 기본값은 아닙니다. 다른 모델로 바꾸더라도 Flex 사용이 자동 강제되지는 +않습니다. 상위 API로 `service_tier: "flex"`를 전달하지만, 실제 처리 가능 여부, +요금과 지연 시간은 공급자에 따라 다릅니다. 메뉴나 요청 로그만으로 Flex 과금이 +확정되지는 않습니다. + ### Codex 데스크톱에서 Ultra Fast 선택 `service_tier: "ultrafast"`를 지원하는 프로바이더에는 전송 매핑을 한 번 설정하면 됩니다. diff --git a/src/codex/catalog/build-entries.ts b/src/codex/catalog/build-entries.ts index 205e78d77ba..785b5f6314c 100644 --- a/src/codex/catalog/build-entries.ts +++ b/src/codex/catalog/build-entries.ts @@ -485,6 +485,8 @@ export interface ObservedCatalogMergeInput { readonly ultraFastTier?: boolean; /** Current captured wire declarations; disk rows and templates never populate this set. */ readonly configuredUltraFastSlugs?: ReadonlySet; + /** Current config-only verdict for persisted rows (not authority from the old disk tier). */ + readonly retainedFlexPolicyBySlug?: ReadonlyMap; readonly catalogModels: readonly RawEntry[]; readonly baselineCatalogModels: readonly RawEntry[]; readonly routedEntries: readonly RawEntry[]; @@ -532,6 +534,7 @@ export interface ObservedCatalogMergeInput { export function mergeCatalogEntriesFromObservedState({ ultraFastTier = false, configuredUltraFastSlugs = new Set(), + retainedFlexPolicyBySlug = new Map(), catalogModels, baselineCatalogModels, routedEntries, @@ -889,6 +892,25 @@ export function mergeCatalogEntriesFromObservedState({ || trustedAccountBoundNativeCatalogSlug(entry) !== undefined) continue; applyConfiguredUltraFast(entry, operatorRows.get(entry.slug), configuredUltraFastSlugs.has(entry.slug), ultraFastTier); } + // Degraded discovery preserves disk rows, so they bypass fresh tier generation. + // Revoke only an explicitly disallowed Flex tier according to today's provider policy; + // a transient outage must not erase Flex while the configured wire still permits it. + const persistedRoutedSet = new Set(preservedRoutedEntries); + for (const entry of finalRoutedEntries) { + if (!persistedRoutedSet.has(entry)) continue; + const slug = typeof entry.slug === "string" ? entry.slug : ""; + const excluded = isNativeAliasCatalogEntry(entry) || isExactComboCatalogEntry(entry, exactComboSlugs); + if (!excluded && retainedFlexPolicyBySlug.get(slug) !== false) continue; + if (Array.isArray(entry.service_tiers)) { + entry.service_tiers = entry.service_tiers.filter(tier => tier?.id !== "flex"); + } + if (Array.isArray(entry.additional_speed_tiers)) { + entry.additional_speed_tiers = entry.additional_speed_tiers.filter(tier => tier !== "flex"); + } + for (const field of ["service_tier", "default_service_tier"] as const) { + if (entry[field] === "flex") delete entry[field]; + } + } const finalRoutedEntrySet = new Set(finalRoutedEntries); const degradedPreservedCount = preservedRoutedEntries.filter(entry => { if (!finalRoutedEntrySet.has(entry)) return false; diff --git a/src/codex/catalog/configured-tiers.ts b/src/codex/catalog/configured-tiers.ts index 7eb723550b2..9cbe3cbfa90 100644 --- a/src/codex/catalog/configured-tiers.ts +++ b/src/codex/catalog/configured-tiers.ts @@ -1,10 +1,21 @@ import { COMBO_NAMESPACE } from "../../combos"; +import type { OcxProviderConfig } from "../../types"; +import { decodeRoutedModelId } from "../../providers/slug-codec"; +import { fastPolicyForModel } from "../../providers/service-tier"; import type { ResolvedFastPolicy } from "../../providers/fastwire"; import { catalogModelSlug, type CatalogModel, type RawEntry } from "./parsing"; import { preserveOperatorUltraFastTiers } from "./operator-tiers"; const MANAGED_ULTRAFAST = "opencodex_managed_ultrafast"; +/** Advertise Flex only when the final route can forward the caller's exact flex tier. */ +export function supportsConfiguredFlex(policy: ResolvedFastPolicy | undefined): boolean { + return policy?.eligibility === "eligible" + && policy.fastWire?.kind === "service-tier" + && policy.fastWire.canonicalToWire.flex === "flex" + && policy.forwardCallerTier; +} + /** Advertise Ultra Fast only when the eligible final wire explicitly maps that exact tier. */ export function supportsConfiguredUltraFast(policy: ResolvedFastPolicy | undefined): boolean { return policy?.eligibility === "eligible" @@ -48,3 +59,62 @@ export function applyConfiguredUltraFast( // Advertising a selectable capability does not opt the user into its increased usage. entry[MANAGED_ULTRAFAST] = entry.slug; } + +/** + * Re-evaluate persisted routed Flex choices using current provider configuration. Degraded + * discovery keeps disk rows but cannot let a withdrawn wire declaration keep old picker tiers. + * Only rows tied to a configured provider are judged; unmanaged foreign rows are untouched. + */ +export function retainedFlexPolicyBySlug( + providers: Readonly>, + rows: readonly RawEntry[], + observedModels: readonly CatalogModel[], +): ReadonlyMap { + const result = new Map(); + const currentModels = new Map(); + for (const model of observedModels) { + const slug = catalogModelSlug(model); + const candidates = currentModels.get(slug) ?? []; + candidates.push(model); + currentModels.set(slug, candidates); + } + for (const row of rows) { + const slug = typeof row.slug === "string" ? row.slug : ""; + const slash = slug.indexOf("/"); + if (slash <= 0) continue; + const namespace = slug.slice(0, slash); + const owner = Object.entries(providers).find(([name, provider]) => + name === namespace || provider.alias === namespace); + if (!owner) continue; + const [name, provider] = owner; + if (provider.disabled === true || namespace === COMBO_NAMESPACE || name === COMBO_NAMESPACE) { + result.set(slug, false); + continue; + } + const observed = currentModels.get(slug); + if (observed?.length) { + // The current gather re-applies captured policy hints; conflicting rows fail closed. + result.set(slug, observed.every(model => + model.provider !== COMBO_NAMESPACE && model.nativeAlias !== true + && model.supportsServiceTier === true && model.supportsFlexTier === true)); + continue; + } + const requested = slug.slice(slash + 1); + const aliased = Object.entries(provider.modelAliases ?? {}) + .filter(([, alias]) => alias === requested).map(([id]) => id); + // A non-unique short alias is not authority for a capability declaration. + if (aliased.length > 1) { + result.set(slug, false); + continue; + } + const knownIds = [ + ...(provider.models ?? []), + ...Object.keys(provider.modelSupportsServiceTier ?? {}), + ...Object.keys(provider.modelAliases ?? {}), + ...Object.keys(provider.modelAdapters ?? {}), + ]; + const modelId = aliased[0] ?? decodeRoutedModelId(requested, knownIds); + result.set(slug, supportsConfiguredFlex(fastPolicyForModel(provider, modelId, name))); + } + return result; +} diff --git a/src/codex/catalog/effort.ts b/src/codex/catalog/effort.ts index 87f9dd0cde2..1af507ceebc 100644 --- a/src/codex/catalog/effort.ts +++ b/src/codex/catalog/effort.ts @@ -184,6 +184,8 @@ export function applyCatalogModelMetadata(entry: RawEntry, model?: CatalogModel) entry.supports_reasoning_summaries = model.supportsReasoningSummaries; } if (model.supportsServiceTier === true) { + const eligibleRoutedFlex = model.supportsFlexTier === true + && model.provider !== COMBO_NAMESPACE && model.nativeAlias !== true; const nativeFast = model.codexForwardNativeCapabilityAlias && Array.isArray(entry.service_tiers) ? entry.service_tiers.find(tier => tier?.id === "priority") : undefined; @@ -192,8 +194,12 @@ export function applyCatalogModelMetadata(entry: RawEntry, model?: CatalogModel) id: "priority", name: "Fast", description: model.fastTierDescription ?? nativeFast?.description ?? "1.5x speed, increased usage", - }]; - entry.additional_speed_tiers = ["fast"]; + }, ...(eligibleRoutedFlex ? [{ + id: "flex", + name: "Flex", + description: "Capacity-dependent processing; availability and latency may vary", + }] : [])]; + entry.additional_speed_tiers = eligibleRoutedFlex ? ["fast", "flex"] : ["fast"]; } stampCapabilityProvenance(entry, model); } diff --git a/src/codex/catalog/model-hints.ts b/src/codex/catalog/model-hints.ts index 6d9a13d060f..c2bad364722 100644 --- a/src/codex/catalog/model-hints.ts +++ b/src/codex/catalog/model-hints.ts @@ -1,7 +1,7 @@ import { canInferVendorContext, vendorContextInference } from "./vendor-context"; import { effectiveProviderAlias, effectiveProviderAliasDecision } from "../../providers/default-aliases"; import { initialModelSelectionPending } from "../../providers/initial-model-selection"; -import { supportsConfiguredUltraFast } from "./configured-tiers"; +import { supportsConfiguredFlex, supportsConfiguredUltraFast } from "./configured-tiers"; import { execFileSync } from "node:child_process"; import { createHash, createHmac, randomBytes } from "node:crypto"; import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs"; @@ -319,6 +319,7 @@ export function applyProviderConfigHints( contextCapped: previousContextCapped, supportsServiceTier: _staleServiceTier, supportsUltraFastTier: _staleUltraFastTier, + supportsFlexTier: _staleFlexTier, fastTierDescription: _staleFastTierDescription, providerAlias: _staleProviderAlias, suppressSyntheticMax: _staleSuppressSyntheticMax, @@ -360,6 +361,7 @@ export function applyProviderConfigHints( ...(typeof supportsVerbosity === "boolean" ? { supportsVerbosity } : {}), ...(typeof supportsServiceTier === "boolean" ? { supportsServiceTier } : {}), supportsUltraFastTier: supportsConfiguredUltraFast(fastPolicy), + supportsFlexTier: supportsConfiguredFlex(fastPolicy), ...(supportsServiceTier === true && fastPolicy.fastTierDescription !== undefined ? { fastTierDescription: fastPolicy.fastTierDescription } : {}), diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index 27693aa4cdc..af119b37a2a 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -133,6 +133,8 @@ export interface CatalogModel { supportsServiceTier?: boolean; /** Captured eligible service-tier wire explicitly maps ultrafast to ultrafast. */ supportsUltraFastTier?: boolean; + /** Explicitly verified exact Flex wire and foreign caller-tier forwarding on this route. */ + supportsFlexTier?: boolean; /** Optional provider-specific copy for the advertised Fast tier. */ fastTierDescription?: string; supportsReasoningSummaries?: boolean; diff --git a/src/codex/catalog/retained-sync.ts b/src/codex/catalog/retained-sync.ts index 4994f28dca7..5f959ac3ee9 100644 --- a/src/codex/catalog/retained-sync.ts +++ b/src/codex/catalog/retained-sync.ts @@ -1,5 +1,5 @@ import { projectAntigravitySelectedModels } from "../../providers/antigravity-effort-families"; -import { configuredUltraFastSlugs } from "./configured-tiers"; +import { configuredUltraFastSlugs, retainedFlexPolicyBySlug } from "./configured-tiers"; import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { initializeConfigOwnership } from "../../lib/config-ownership"; @@ -511,6 +511,7 @@ function writeRetainedCatalogSync({ catalog.models = mergeCatalogEntriesFromObservedState({ ultraFastTier: ultraFastTierEnabled(config), configuredUltraFastSlugs: configuredUltraFastSlugs(orderedGoModels), + retainedFlexPolicyBySlug: retainedFlexPolicyBySlug(config.providers ?? {}, catalogModelsForMerge, orderedGoModels), modelPickerOrder, accountSelectors, catalogModels: catalogModelsForMerge, diff --git a/src/codex/catalog/routed-gather.ts b/src/codex/catalog/routed-gather.ts index 13bf24a7064..23fafa4778c 100644 --- a/src/codex/catalog/routed-gather.ts +++ b/src/codex/catalog/routed-gather.ts @@ -1,7 +1,7 @@ import { canInferVendorContext } from "./vendor-context"; import { effectiveProviderAlias, effectiveProviderAliasDecision } from "../../providers/default-aliases"; import { initialModelSelectionPending } from "../../providers/initial-model-selection"; -import { supportsConfiguredUltraFast } from "./configured-tiers"; +import { supportsConfiguredFlex, supportsConfiguredUltraFast } from "./configured-tiers"; import { execFileSync } from "node:child_process"; import { createHash, createHmac, randomBytes } from "node:crypto"; import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs"; @@ -364,6 +364,7 @@ async function gatherRoutedModelsUncached( const supported = serviceTierSupportFromPolicy(policy); if (supported !== undefined) model.supportsServiceTier = supported; model.supportsUltraFastTier = supportsConfiguredUltraFast(policy); + model.supportsFlexTier = supportsConfiguredFlex(policy); if (supported === true && policy.fastTierDescription !== undefined) model.fastTierDescription = policy.fastTierDescription; } } @@ -661,6 +662,7 @@ async function gatherRoutedModelsUncached( ...(cm.defaultReasoningEffort ? { defaultReasoningEffort: cm.defaultReasoningEffort } : {}), ...(typeof supportsServiceTier === "boolean" ? { supportsServiceTier } : {}), supportsUltraFastTier: supportsConfiguredUltraFast(fastPolicy), + supportsFlexTier: supportsConfiguredFlex(fastPolicy), ...(supportsServiceTier === true && fastPolicy?.fastTierDescription !== undefined ? { fastTierDescription: fastPolicy.fastTierDescription } : {}), diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts index fc4de4a6b20..99662a71aa9 100644 --- a/src/codex/convergence.ts +++ b/src/codex/convergence.ts @@ -1,5 +1,5 @@ import { projectAntigravitySelectedModels } from "../providers/antigravity-effort-families"; -import { configuredUltraFastSlugs } from "./catalog/configured-tiers"; +import { configuredUltraFastSlugs, retainedFlexPolicyBySlug } from "./catalog/configured-tiers"; import { join } from "node:path"; import { sameCatalogHealPath } from "./catalog/heal-observation"; @@ -373,6 +373,7 @@ function prepareCatalog( const mergedModels = mergeCatalogEntriesFromObservedState({ ultraFastTier: ultraFastTierEnabled(config), configuredUltraFastSlugs: configuredUltraFastSlugs(ordered), + retainedFlexPolicyBySlug: retainedFlexPolicyBySlug(config.providers ?? {}, catalogModels, ordered), modelPickerOrder, accountSelectors, catalogModels, diff --git a/tests/codex-integration/codex-catalog.test.ts b/tests/codex-integration/codex-catalog.test.ts index 62c5350666d..1dfe3a4eb02 100644 --- a/tests/codex-integration/codex-catalog.test.ts +++ b/tests/codex-integration/codex-catalog.test.ts @@ -2043,7 +2043,7 @@ describe("provider discovered model display names", () => { maxOutputTokens: 500_000, // Classified by the 2026-09-13 OAuth-lane probe: grok-4.6 is Fast-eligible on // either xAI transport, so the hint fills the capability and its description. - supportsServiceTier: true, supportsUltraFastTier: false, + supportsServiceTier: true, supportsUltraFastTier: false, supportsFlexTier: false, fastTierDescription: "Priority processing; tier pricing applies on key auth only", }); expect(catalogModelSlug(output)).toBe("xai/grok-4.6"); diff --git a/tests/codex-integration/configured-ultrafast-picker.test.ts b/tests/codex-integration/configured-ultrafast-picker.test.ts index 2afd05a3763..17999c05c55 100644 --- a/tests/codex-integration/configured-ultrafast-picker.test.ts +++ b/tests/codex-integration/configured-ultrafast-picker.test.ts @@ -7,7 +7,7 @@ import type { OcxProviderConfig } from "../../src/types"; import { captureFastPolicyAuthority, fastPolicyForModel } from "../../src/providers/service-tier"; import { decideTier } from "../../src/providers/fastwire"; import { applyProviderConfigHints } from "../../src/codex/catalog/model-hints"; -import { configuredUltraFastSlugs } from "../../src/codex/catalog/configured-tiers"; +import { configuredUltraFastSlugs, retainedFlexPolicyBySlug } from "../../src/codex/catalog/configured-tiers"; import { buildCatalogEntriesFromObservedState, mergeCatalogEntriesFromObservedState, type ObservedCatalogMergeInput } from "../../src/codex/catalog/build-entries"; import type { CatalogModel, RawEntry } from "../../src/codex/catalog/parsing"; import { repoRoot } from "../helpers/repo-root"; @@ -118,6 +118,65 @@ describe("configured Ultra Fast in the native Codex speed picker", () => { expect(merge([{ ...operator[0], default_service_tier: "ultrafast" }])[0]!.default_service_tier).toBeNull(); }); + test("degraded retained rows drop revoked Flex but preserve authorized Flex", () => { + const stored = merge(); + const flex = { id: "flex", name: "Flex" }; + stored[0]!.service_tiers.push(flex); + stored[0]!.additional_speed_tiers.push("flex"); + const configured = provider({ + fastWire: { kind: "service-tier", canonicalToWire: { priority: "priority", flex: "flex" }, foreignCallerTiers: "verbatim" }, + }); + const run = (active: typeof configured) => merge(stored, { + routedEntries: [], degradedProviderNames: new Set(["gateway"]), + configuredUltraFastSlugs: new Set(), + retainedFlexPolicyBySlug: retainedFlexPolicyBySlug({ gateway: active }, stored, []), + })[0]!; + expect(run(configured).additional_speed_tiers).toContain("flex"); + const revoked = run(provider()); + expect(revoked.service_tiers.map((tier: { id: string }) => tier.id)).not.toContain("flex"); + expect(revoked.additional_speed_tiers).not.toContain("flex"); + expect(run(provider({ modelSupportsServiceTier: { "task-model": false } })).additional_speed_tiers) + .not.toContain("flex"); + expect(stored[0]!.additional_speed_tiers).toContain("flex"); + }); + + test("retained Flex policy excludes physical combo providers and combo aliases without discovery", () => { + const flexProvider = provider({ + fastWire: { kind: "service-tier", canonicalToWire: { priority: "priority", flex: "flex" }, foreignCallerTiers: "verbatim" }, + }); + const check = (providers: Record, rowSlug: string) => + retainedFlexPolicyBySlug(providers, [{ slug: rowSlug }], []).get(rowSlug); + + expect(check({ combo: flexProvider }, "combo/task-model")).toBe(false); + expect(check({ combo: { ...flexProvider, alias: "bundle" } }, "bundle/task-model")).toBe(false); + expect(check({ gateway: { ...flexProvider, alias: "combo" } }, "combo/task-model")).toBe(false); + expect(check({ gateway: flexProvider }, "gateway/task-model")).toBe(true); + }); + + test("degraded retained encoded slugs resolve adapter-only native IDs before Flex policy", () => { + const rowSlug = "gateway/a-b"; + const base = provider({ + models: [], + supportsServiceTier: true, + modelSupportsServiceTier: {}, + modelAdapters: { "a/b": "openai-chat" }, + fastWire: { + kind: "service-tier", canonicalToWire: { priority: "priority", flex: "flex" }, + foreignCallerTiers: "verbatim", + }, + }); + const retainedPolicy = (config: OcxProviderConfig) => + retainedFlexPolicyBySlug({ gateway: config }, [{ slug: rowSlug }], []).get(rowSlug); + + // Without decoding, a-b incorrectly inherits the provider's Responses adapter + // and permits Flex, although the native a/b Chat route cannot forward that tier. + expect(fastPolicyForModel(base, "a-b", "gateway").forwardCallerTier).toBe(true); + expect(fastPolicyForModel(base, "a/b", "gateway").forwardCallerTier).toBe(false); + expect(retainedPolicy(base)).toBe(false); + // A verified Chat service-tier opt-in makes that exact native route eligible. + expect(retainedPolicy({ ...base, chatServiceTier: true })).toBe(true); + }); + test("selection and disabled-model policy still remove the entire row", () => { expect(merge([], { disabledModels: new Set([slug]) })).toEqual([]); expect(merge([], { selectedModelsByProvider: new Map([["gateway", new Set()]]) })).toEqual([]); diff --git a/tests/codex-integration/ultrafast-live-catalog.test.ts b/tests/codex-integration/ultrafast-live-catalog.test.ts index 8df2ecc7897..cb7c145225a 100644 --- a/tests/codex-integration/ultrafast-live-catalog.test.ts +++ b/tests/codex-integration/ultrafast-live-catalog.test.ts @@ -133,3 +133,50 @@ test("the real HTTP catalog repeatedly publishes only the declared routed tier", } } finally { rmSync(root, { recursive: true, force: true }); } }, 45_000); + +describe("declared Flex choice on routed models", () => { + const flexWire: OcxProviderConfig["fastWire"] = { + kind: "service-tier", + canonicalToWire: { priority: "priority", flex: "flex" }, + foreignCallerTiers: "verbatim", + }; + + test("advertises Flex in both live and derived catalogs only for an eligible final route", () => { + const declared = model({ fastWire: flexWire }); + const before = structuredClone(declared); + const row = build([declared]).find(entry => entry.slug === slug)!; + expect(row.service_tiers.map((tier: { id: string }) => tier.id)).toEqual(["priority", "flex"]); + expect(row.additional_speed_tiers).toEqual(["fast", "flex"]); + expect(row.default_service_tier).toBeNull(); + expect(build([declared]).find(entry => entry.slug === slug)).toEqual(row); + expect(declared).toEqual(before); + }); + + test("ordinary Fast, unavailable wire, and explicit model opt-out never advertise Flex", () => { + const candidates = [ + model({ fastWire: undefined }), + model({ fastWire: { kind: "service-tier", canonicalToWire: { priority: "priority", flex: "priority" }, foreignCallerTiers: "verbatim" } }), + model({ fastWire: flexWire, modelSupportsServiceTier: { "task-model": false } }), + model({ fastWire: { ...flexWire!, foreignCallerTiers: "drop" } }), + ]; + for (const candidate of candidates) { + const row = build([candidate]).find(entry => entry.slug === slug)!; + expect(row.service_tiers?.some((tier: { id: string }) => tier.id === "flex") ?? false).toBe(false); + expect(row.additional_speed_tiers ?? []).not.toContain("flex"); + } + }); + + test("a provider's declared Flex does not leak into unrelated native or combo rows", () => { + const declared = model({ fastWire: flexWire }); + const combo = { ...declared, provider: "combo", id: "task-combo", alias: "friendly-combo", nativeAlias: true }; + const rows = buildCatalogEntries(null, ["gpt-6.1-sol"], [declared, combo], undefined, false, "default", + new Set(["friendly-combo"]), ["main"]); + expect(rows.find(row => row.slug === slug)!.additional_speed_tiers).toContain("flex"); + for (const id of ["friendly-combo", "gpt-6.1-sol", "main/gpt-6.1-sol"]) { + const row = rows.find(row => row.slug === id)!; + expect(row).toBeDefined(); + expect(row.service_tiers?.some((tier: { id: string }) => tier.id === "flex") ?? false).toBe(false); + expect(row.additional_speed_tiers ?? []).not.toContain("flex"); + } + }); +}); diff --git a/tests/routing/fastwire-policy.test.ts b/tests/routing/fastwire-policy.test.ts index f74b004d800..71bb8ca131b 100644 --- a/tests/routing/fastwire-policy.test.ts +++ b/tests/routing/fastwire-policy.test.ts @@ -749,3 +749,25 @@ describe("Responses TierDecision immutability", () => { else expect(outbound.service_tier).toBe(expected); }); }); + +describe("explicit Flex forwarding", () => { + test("eligible Responses wire forwards caller-selected flex, preserving Fast and default behavior", () => { + const policy = resolveFastPolicy({ + ...authorityForMatrix({ + source: "provider-adapter", declaration: "explicit", overrideAllowed: false, + capability: "true", chatForeignTierForward: true, + }), + providerAdapter: "openai-responses", + fastWireDeclaration: { + kind: "service-tier", + canonicalToWire: { priority: "priority", flex: "flex" }, + foreignCallerTiers: "verbatim", + }, + }, MODEL); + const decision = decideTier(policy, undefined, "flex"); + expect(decision).toEqual({ kind: "forward-caller" }); + expect(tierValueAfterDecision(decision, "flex")).toBe("flex"); + expect(tierValueAfterDecision(decideTier(policy, true, "flex"), "flex")).toBe("priority"); + expect(tierValueAfterDecision(decideTier(policy, false, "flex"), "flex")).toBeUndefined(); + }); +}); From 162beb3d1509291e52f808bd4f11e389bf17c06f Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 26 Oct 2026 20:55:38 +0900 Subject: [PATCH 087/101] fix(anthropic): preserve cache prefixes during routed compaction (#7749) Routed Codex compaction on the Anthropic Messages adapter keeps the client tool catalog, system prompt and tool options byte-identical to the ordinary request so the prompt-cache prefix survives. Hosted server tools or a mandatory choice make the adapter send tool_choice none after building tools and system from the caller's choice; a summary guard drops tool events and fails tool-bearing or empty summaries. Image normalization and 413 recovery are unchanged. Refs #7307; partial carry of #7308. Co-authored-by: S0RYUASUKA <64878945+S0RYUASUKA@users.noreply.github.com> --- .../src/content/docs/reference/adapters.md | 8 + scripts/test-layout/layout-replay.json | 1 + src/adapters/anthropic.ts | 3 + src/responses/compaction-summary.ts | 45 +++ src/server/responses/adapter-delivery.ts | 12 +- src/server/responses/request-sidecar-auth.ts | 2 + src/server/responses/response-effects.ts | 32 +- src/server/responses/sidecar-execution.ts | 2 +- src/types/request.ts | 5 + structure/providers-and-adapters.md | 2 +- structure/transports/responses-failover.md | 13 + structure/transports/responses.md | 8 +- tests/fixtures/test-layout-expected.json | 1 + ...ponses-anthropic-compaction-prefix.test.ts | 349 ++++++++++++++++++ 14 files changed, 466 insertions(+), 17 deletions(-) create mode 100644 src/responses/compaction-summary.ts create mode 100644 tests/responses/responses-anthropic-compaction-prefix.test.ts diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index b81796182f1..ef88ecb76b5 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -329,6 +329,14 @@ MiMo model Command Code serves. **Auth:** `key` (`x-api-key` by default, or `Authorization: Bearer` with `apiKeyTransport: "bearer"`) or `oauth` (Bearer + `anthropic-beta`, for Claude Pro/Max). - Converts messages to Anthropic content blocks (text, base64 image, `tool_use`, `thinking`). +- Routed Codex compaction keeps the client tool catalog, system prompt and tool-selection + options to preserve their prompt-cache prefix. Summary requests disable search/media + sidecars; upstream-hosted tools retain their declarations with `tool_choice: none`, as does a + mandatory tool choice, while the advertised catalog stays the same. A request that advertises a + sidecar web-search tool (for example Exa) still changes its tool prefix on compaction. + A summary that attempts a tool call fails without publishing a function call or replacing + history. A successful summary returns exactly one compaction item. Image normalization + and the existing one-shot tighter-image retry after HTTP 413 remain unchanged. - Translated Anthropic Messages reasoning replay shares the request translation budget, including encoding/decoding copy overhead. Requests exceeding it return HTTP 413 with `translation_buffer_limit`; signatures and opaque reasoning data are never truncated to fit. diff --git a/scripts/test-layout/layout-replay.json b/scripts/test-layout/layout-replay.json index 3b8ab38bea1..602b94337f2 100644 --- a/scripts/test-layout/layout-replay.json +++ b/scripts/test-layout/layout-replay.json @@ -1,4 +1,5 @@ { + "responses-anthropic-compaction-prefix.test.ts": "responses", "responses-replay-metadata-principal.test.ts": "responses", "responses-combo-replay-principal.test.ts": "responses", "responses-translated-replay-principal.test.ts": "responses" diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 4251de21e29..643250d9680 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -1212,6 +1212,9 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti && settledToolChoice.type !== "none") { body.tool_choice = { ...settledToolChoice, disable_parallel_tool_use: true }; } + // A routed compaction summary keeps every tools/system byte derived from the caller's + // choice above, so only the final execution choice changes. + if (parsed._summaryProhibitsToolCalls && body.tools) body.tool_choice = { type: "none" }; const url = resolveAnthropicMessagesUrl(provider); // Anthropic fast mode: `speed` is only accepted beside its beta; without it the API diff --git a/src/responses/compaction-summary.ts b/src/responses/compaction-summary.ts new file mode 100644 index 00000000000..77f4905daa5 --- /dev/null +++ b/src/responses/compaction-summary.ts @@ -0,0 +1,45 @@ +import type { AdapterEvent } from "../types"; +import { releaseTranslatedEvent, type TranslatorBudget } from "../lib/translator-budget"; +import { isTruncatedStopReason } from "./truncated-stop-reason"; + +/** Keep client/hosted tool events out of a routed Anthropic summary's compaction item. */ +export async function* guardCompactionSummary( + events: AsyncIterable, + budget: TranslatorBudget, +): AsyncGenerator { + let hasText = false; + let attemptedTool = false; + for await (const event of events) { + if (event.type === "text_delta" && !event.anthropicThinkingSignature) { + hasText ||= event.text.trim().length > 0; + yield event; + continue; + } + if (event.type === "error" || event.type === "incomplete") { + yield event; + return; + } + if (event.type === "done") { + if (!isTruncatedStopReason(event.stopReason) + // An absent stop reason is accepted, as on every other completion path: some + // Anthropic-compatible endpoints omit it. An explicit non-terminal reason is not. + && (attemptedTool || !hasText + || (event.stopReason !== undefined && event.stopReason !== "end_turn" && event.stopReason !== "stop_sequence"))) { + releaseTranslatedEvent(event, budget); + yield { + type: "error", status: 502, errorType: "upstream_error", + code: "compaction_invalid_summary", retryable: false, + message: "Compaction requires a non-empty text summary without tool calls and a normal completion.", + usage: event.usage, + }; + } else { + yield event; + } + return; + } + if (event.type === "tool_call_start" || event.type === "tool_call_delta" || event.type === "tool_call_end" + || event.type === "web_search_call_begin" || event.type === "web_search_call_end") attemptedTool = true; + releaseTranslatedEvent(event, budget); + yield { type: "heartbeat" }; + } +} diff --git a/src/server/responses/adapter-delivery.ts b/src/server/responses/adapter-delivery.ts index 01a1af78cdc..37ccbc4554e 100644 --- a/src/server/responses/adapter-delivery.ts +++ b/src/server/responses/adapter-delivery.ts @@ -28,6 +28,7 @@ import { sanitizeNonReplayableUpstreamError } from "./non-replayable-error"; import { isNonReplayableResponse } from "../../lib/upstream-retry"; import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; import { guardEmptyToolCatalogEvents, guardEmptyToolCatalogStream } from "../../lib/empty-tool-catalog"; +import { guardCompactionSummary } from "../../responses/compaction-summary"; /** One responsibility of the Responses request pipeline; state owners are explicit. */ export async function deliverAdapterResponse( @@ -183,7 +184,9 @@ export async function deliverAdapterResponse( continuation: fetchGuardedEmptyCompletionRetry, }) : advisorStream; - const guardedEventStream = guardEmptyToolCatalogStream(completionGuardedEventStream, clientToolCatalog, translatorBudget); + const catalogGuardedEventStream = guardEmptyToolCatalogStream(completionGuardedEventStream, clientToolCatalog, translatorBudget); + const guardedEventStream = routedCompaction && transportState.activeAdapter.name === "anthropic" + ? guardCompactionSummary(catalogGuardedEventStream, translatorBudget) : catalogGuardedEventStream; const { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, bareCustomToolNames, toolSearchToolNames } = deliveryToolBridgeMaps; // One completion owner for both deliveries: the bridge calls it from its terminal, the // direct client encoder from the fold of the same events. @@ -329,6 +332,13 @@ export async function deliverAdapterResponse( } else { events = guardedEvents; } + if (routedCompaction && transportState.activeAdapter.name === "anthropic") { + const summaryEvents: AdapterEvent[] = []; + for await (const event of guardCompactionSummary((async function* () { yield* events; })(), translatorBudget)) { + summaryEvents.push(event); + } + events = summaryEvents; + } } catch (error) { if (error instanceof ResponseBodyInactivityError) { return formatErrorResponse(504, "upstream_error", "Upstream response body stalled before completing"); diff --git a/src/server/responses/request-sidecar-auth.ts b/src/server/responses/request-sidecar-auth.ts index 63a00d9655e..d345f01db16 100644 --- a/src/server/responses/request-sidecar-auth.ts +++ b/src/server/responses/request-sidecar-auth.ts @@ -52,6 +52,7 @@ export async function prepareResponsesSidecarAuth( const visionDescribeTerminal = options.visionDescribeTerminal === true; const routedCompaction = parsed._compactionRequest === true && (!supportsNativeResponsesCompactionTrigger(route.provider) || parsed._portableCompaction === true); + const preserveCompactionPrefix = routedCompaction && transportState.adapter.name === "anthropic"; if (routedCompaction) parsed.context.messages = omitEarlierCompactionImages(parsed.context.messages); const needsOpenAiVision = !visionDescribeTerminal && shouldResolveOpenAiVisionSidecar(config, route.provider, route.modelId, parsed, route.providerName); @@ -158,6 +159,7 @@ export async function prepareResponsesSidecarAuth( return { openAiSidecar, routedCompaction, + preserveCompactionPrefix, }; } diff --git a/src/server/responses/response-effects.ts b/src/server/responses/response-effects.ts index e901594e401..199a8beb36a 100644 --- a/src/server/responses/response-effects.ts +++ b/src/server/responses/response-effects.ts @@ -1,3 +1,4 @@ +import { requiresToolCall } from "../../types"; import type { ResponsesRequestContext, ResponsesAdmissionState } from "./core-options"; import type { PreparedResponsesRequest } from "./request-prepare"; import type { ResponsesSidecarAuth } from "./request-sidecar-auth"; @@ -26,11 +27,11 @@ export function createResponsesEffects( | "route" | "substituteMainCredential" >, - sidecarState: Pick, + sidecarState: Pick & Partial>, ) { const { options, req, config } = requestContext; const { parsed, poolAffinityKey, route, substituteMainCredential } = requestState; - const { routedCompaction } = sidecarState; + const { routedCompaction, preserveCompactionPrefix } = sidecarState; const recordTerminalOutcomes = options.recordTerminalOutcomes !== false; @@ -67,8 +68,8 @@ export function createResponsesEffects( // Remote compaction v2 on a ROUTED model: Codex sent `compaction_trigger` and requires exactly // one `{type:"compaction"}` output item (codex-rs compact_remote_v2.rs). Passthrough handles it - // natively upstream; here we run the routed model as a plain summarizer — no tools, no web-search - // sidecar — and the bridge appends the synthetic compaction item (src/responses/compaction.ts). + // natively upstream; here the routed model summarizes without sidecar execution, and the + // bridge appends the synthetic compaction item (src/responses/compaction.ts). // A Responses-shaped wire does not imply support for Codex's private // `compaction_trigger` item — only the canonical ChatGPT backend speaks that // contract. An API-key gateway would receive the trigger, answer with an ordinary @@ -87,12 +88,21 @@ export function createResponsesEffects( } }; if (routedCompaction) { - delete parsed.context.tools; delete parsed._webSearch; - delete parsed._anthropicServerTools; - delete parsed._anthropicServerReplay; - delete parsed.options.toolChoice; - delete parsed.options.parallelToolCalls; + if (!preserveCompactionPrefix) { + delete parsed.context.tools; + delete parsed._anthropicServerTools; + delete parsed._anthropicServerReplay; + delete parsed.options.toolChoice; + delete parsed.options.parallelToolCalls; + } else if (parsed._anthropicServerTools?.length || requiresToolCall(parsed.options.toolChoice)) { + // Hosted tools execute upstream, and a mandatory choice would force a call the summary + // guard must reject. Keep the choice itself so the adapter derives the same tools and + // system bytes; the adapter replaces only the final wire tool_choice with "none". + // tool_choice changes invalidate message cache, not tools/system (Anthropic caching docs). + parsed._summaryProhibitsToolCalls = true; + } + // Client tool events are rejected by guardCompactionSummary before reaching Codex. // The compaction turn is a plain prose summary; a surviving structured-output format // would force schema-constrained JSON into the synthetic compaction item. The flag and // the raw `text` control go too: the key-mode openai-responses adapter builds from @@ -105,7 +115,9 @@ export function createResponsesEffects( } parsed.context.messages = [ ...parsed.context.messages, - { role: "user", content: COMPACT_PROMPT, timestamp: Date.now() }, + { role: "user", content: preserveCompactionPrefix + ? `${COMPACT_PROMPT}\n\nReturn only the handoff summary. Do not call tools or continue the task.` + : COMPACT_PROMPT, timestamp: Date.now() }, ]; } diff --git a/src/server/responses/sidecar-execution.ts b/src/server/responses/sidecar-execution.ts index 932e87c6080..529d3dcce68 100644 --- a/src/server/responses/sidecar-execution.ts +++ b/src/server/responses/sidecar-execution.ts @@ -201,7 +201,7 @@ export async function executeResponsesSidecars( } // Image / web-search sidecars: plan once, then dispatch with runTurn-aware priority. - // Routed-compaction turns must NOT hit the image bridge: compaction clears tools/_webSearch but + // Routed-compaction turns must NOT hit the image bridge: compaction disables tool execution but // leaves _imageGeneration, so planImageBridge would activate and return a normal Responses // completion instead of the synthetic compaction item Codex expects (#424). // diff --git a/src/types/request.ts b/src/types/request.ts index ef1577f3b27..b184d4ca450 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -76,6 +76,11 @@ export interface OcxParsedRequest { * them, and only for api.anthropic.com; every other destination never sees them. */ _anthropicServerTools?: readonly Record[]; + /** + * Routed Anthropic compaction summary: build tools and system exactly as the ordinary request + * would (prompt-cache prefix), then send tool_choice none so no client or hosted tool runs. + */ + _summaryProhibitsToolCalls?: boolean; /** * Completed server-tool exchanges from the Claude client's history, keyed by the block that * followed them (`call:` / `text:`). Same audience as `_anthropicServerTools`. diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 85a331ebe4f..a29873eb4b0 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -7,7 +7,7 @@ Claude CLI replay, client tool capture and usage admission follow [Claude CLI re API-key credit-balance refusals follow the [Anthropic billing recovery contract](providers/anthropic-key-billing.md). Managed native Anthropic serving UUID and observed CLI header continuity follow [native Messages](data-planes/protocol-paths.md#managed-native-messages); generated Responses retain the adapter's compatibility fingerprint. -Opus 5.5 commentary follows the [signed progress-update contract](providers/anthropic-progress.md). Per-account usage thresholds follow the [Anthropic account thresholds contract](providers/anthropic-account-thresholds.md). +Opus 5.5 commentary follows the [signed progress-update contract](providers/anthropic-progress.md). Per-account usage thresholds follow the [Anthropic account thresholds contract](providers/anthropic-account-thresholds.md). Routed Anthropic compaction preserves the client tool/system prefix and guards summary output under the [compaction contract](transports/responses-failover.md#compaction-routing-overrides); image normalization and HTTP 413 recovery retain their existing policy. A shared-quota Anthropic 429 or classified pre-output account 403 records the served account's cooldown even when the request has used its allowed retry sends. That final account remains excluded on the next request; combo target cooling for 429 is skipped only after the matching account cooldown is present. Exact revoked-token 401s persist generation-fenced reauthentication under [the Anthropic recovery contract](providers/anthropic-account-pool.md#revoked-oauth-access-token-recovery). The Anthropic helper sends share the same routing authority: `getAnthropicSidecarAccessToken` resolves the vision-describe and web-search sidecars' helper model through the same first-match route decision, so a routed send authenticates as the route's own account rather than whatever pool account happens to be active. A strict route with no eligible account fails the helper locally instead of silently falling back to the active outsider, matching the primary-traffic contract; callers without a pool config keep the plain stored-credential path. diff --git a/structure/transports/responses-failover.md b/structure/transports/responses-failover.md index e328c85e012..840859268d0 100644 --- a/structure/transports/responses-failover.md +++ b/structure/transports/responses-failover.md @@ -262,6 +262,19 @@ including when the destination is a combo, so request history names the phase th ## Compaction routing overrides +For routed v2 summaries served by the resolved Anthropic Messages adapter, `src/server/responses/response-effects.ts` +preserves client tool definitions, system text, `toolChoice` and `parallelToolCalls`. Other adapters retain tool removal. +Search/media sidecar execution stays disabled; Anthropic server-tool declarations and replay remain, +with wire `tool_choice: none` when server tools exist, preventing upstream execution. +[Anthropic prompt caching](https://platform.claude.com/docs/en/build-with-claude/prompt-caching#what-invalidates-the-cache) +orders the prefix as tools, system, messages: changing tool definitions invalidates all three; +changing tool choice invalidates message cache only. Client-only summaries keep an optional choice unchanged; a mandatory choice (required, named, or required allowed tools) keeps the caller's choice for catalog derivation and sets `_summaryProhibitsToolCalls`; `src/adapters/anthropic.ts` builds tools and system from that choice, provider name transforms included, then sends `tool_choice: none`, so tools and system stay byte-identical. Search sidecar declarations are not injected for summaries, so a request whose ordinary turn advertised a sidecar search tool still changes its tool prefix. +`src/responses/compaction-summary.ts` suppresses tool/search events and rejects tool attempts, +empty summaries and abnormal completions before delivery; +only successful prose reaches the single synthetic compaction item. Truncated completions retain the existing incomplete outcome. +Signed progress is not summary prose. +`tests/responses/responses-anthropic-compaction-prefix.test.ts` covers the prefix and both delivery modes; image normalization and 413 recovery retain their existing policy. + `src/server/responses/compaction-routing.ts` applies `compactionRouting` before model routing in both `request-prepare.ts` and `compact.ts`. It requires explicit `request_kind: "compaction"` in `x-codex-turn-metadata`, supplied as a header or embedded in Responses `client_metadata`, and on diff --git a/structure/transports/responses.md b/structure/transports/responses.md index ce089db05bb..92b58237e10 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -92,8 +92,8 @@ a `functions` child of the same name are the duplicate the parser already tolera Replayed call items are lowered whether or not this turn declares the group they name. A catalog can be absent or change mid-session, but the client is still replaying items this layer's own response -restoration stamped with a private `namespace`. Routed compaction runs this boundary before removing -the tool surface so request-local aliases remain available for response restoration. Only +restoration stamped with a private `namespace`. Routed compaction runs this boundary before applying +the summary tool policy so request-local aliases remain available for response restoration. Only `tool_choice` resolves a bare name through the catalog: a history item records which tool actually ran, so re-pointing it at a same-named namespace child would rewrite that record on a coincidence rather than translate it. @@ -418,7 +418,7 @@ The continuation-note and continuation-wire tests establish prompt and wire cont | `shadow-target-availability.ts` | Shadow-call target resolution for `request-prepare.ts`: an unavailable target fails once with `409 intercept_target_unavailable` instead of reaching the native source model or the default provider. | | `request-transport.ts` | Live credential selection, dispatch bindings, adapter replacement and same-target request identity. Copilot Auto renewal onto Chat (including negotiation-401 refresh) signals the native owner before inference; `passthrough-dispatch.ts` reuses its inference-401/429 adapter handoff so JSON and SSE retain the Responses envelope. | | `request-sidecar-auth.ts` | Routed-compaction image projection, sidecar credential resolution and vision preprocessing. | -| `response-effects.ts` | Completion notification, replay publication and live request-tool aliases. | +| `response-effects.ts` | Completion notification, replay publication, live request-tool aliases and routed summary controls; Anthropic keeps client tools/system/options, and flags hosted-tool or mandatory-choice summaries so the adapter sends wire `tool_choice: none`. | | `request-send-budget.ts` | Request-wide send accounting, remaining allowance, the pending recovery permit and the shared ambiguous-resend grant. | | `reset-replay.ts` | Self-contained-request eligibility and the shared operator replacement grant for native Responses and generic translated initial/rebuilt pre-header sends; adapter-owned transports and translated post-header failures are excluded. | | `request-spend.ts` | This request's entries in the durable spend ledger: one per physical send, settled from the terminal usage. | @@ -429,7 +429,7 @@ The continuation-note and continuation-wire tests establish prompt and wire cont | `sidecar-execution.ts`, `sidecar-send-budget.ts` | Image/video versus web-search execution, per-build adapter prompt estimates for usage logging, shared rotation, and adapter-aware prepaid inference/producer ownership under the [spend contract](responses-spend.md#prepaid-initial-sends). | | `completion-policy.ts`, `run-turn-execution.ts` | Empty-completion eligibility and adapter-owned event turns; refreshed retries reject previously failed key bindings and revalidate after each queued admission; adapter-owned CLI spawns receive the post-setup `IncomingMeta.beforePhysicalSend` fence, with [replacement-key readmission](responses-failover.md#upstream-key-attempt-accounting). | | `adapter-dispatch.ts` | Translated initial dispatch, bounded recovery (including [Anthropic thinking-signature recovery](anthropic-thinking-recovery.md)) and the shared continuation retry counter. Initial and rebuilt adapter budget refusals retain the HTTP 413 [byte-accounting contract](byte-accounting.md#request-copy-accounting); ordinary build errors remain 400. | -| `adapter-continuation.ts`, `adapter-delivery.ts` | Continuation event sources and final streaming/buffered bridging; a streamed turn with a `clientEncoder` option is handed to `src/server/inference/client-encoder-delivery.ts` instead of the bridge. | +| `adapter-continuation.ts`, `adapter-delivery.ts` | Continuation event sources and final streaming/buffered bridging; Anthropic routed summaries pass through `src/responses/compaction-summary.ts` before either bridge. A streamed turn with a `clientEncoder` option is handed to `src/server/inference/client-encoder-delivery.ts` instead of the bridge. | Reusable helpers live in `core-auth.ts`, `core-codex-account.ts`, `core-combo.ts`, `core-combo-failure.ts`, `core-combo-native.ts`, `combo-requested-effort.ts`, `core-errors.ts`, `core-lifetime.ts`, `core-normalize.ts`, diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index f938b58673d..9f574a78445 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,5 +1,6 @@ { "workbuddy-quota.test.ts": "providers", "workbuddy-login.test.ts": "oauth", "workbuddy-adapter.test.ts": "adapters", "workbuddy-provider.test.ts": "providers", "link-tunnel-history.test.ts": "clients", "link-readiness.test.ts": "clients", "link-diagnostics.test.ts": "clients", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "responses-phase-inference.test.ts": "responses", "tool-identity-hash.test.ts": "adapters", + "responses-anthropic-compaction-prefix.test.ts": "responses", "service-proxy-env.test.ts": "service", "codex-quota-persistence.test.ts": "codex-integration", "bridge-terminal-refusal.test.ts": "adapters", diff --git a/tests/responses/responses-anthropic-compaction-prefix.test.ts b/tests/responses/responses-anthropic-compaction-prefix.test.ts new file mode 100644 index 00000000000..cd2e49800a7 --- /dev/null +++ b/tests/responses/responses-anthropic-compaction-prefix.test.ts @@ -0,0 +1,349 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createResponsesEffects } from "../../src/server/responses/response-effects"; +import { prepareResponsesSidecarAuth } from "../../src/server/responses/request-sidecar-auth"; +import type { HandleResponsesOptions } from "../../src/server/responses/core-options"; +import { createAnthropicAdapter } from "../../src/adapters/anthropic"; +import { createTranslatorBudget, releaseTranslatedEvent, retainTranslatedEvent } from "../../src/lib/translator-budget"; +import { guardCompactionSummary } from "../../src/responses/compaction-summary"; +import { handleResponses } from "../../src/server/responses/core"; +import { decodeCompactionSummary } from "../../src/responses/compaction"; +import type { AdapterEvent, OcxConfig, OcxParsedRequest } from "../../src/types"; +import { withTestTranslatorBudget } from "../helpers/translator-budget"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { drainCompactionResponseState, installCompactionRoutingAclFixture, removeCompactionFixture } from "../helpers/compaction-routing-fixtures"; + +const tools = [{ name: "read", description: "Read a file", parameters: { type: "object", properties: {} } }]; +function applyEffects(preserveCompactionPrefix: boolean, hosted = false, toolChoice: unknown = "auto") { + const parsed = { + options: { toolChoice, parallelToolCalls: false }, + context: { tools: structuredClone(tools), systemPrompt: ["stable system"], messages: [] }, + _webSearch: { type: "web_search" }, + ...(hosted ? { _anthropicServerTools: [{ type: "web_search_20250305", name: "web_search" }] } : {}), + } as unknown as OcxParsedRequest; + type Args = Parameters; + createResponsesEffects( + { options: {}, req: new Request("http://localhost/v1/responses"), config: {} } as Args[0], + {} as Args[1], { parsed, route: { provider: {} } } as Args[2], + { routedCompaction: true, preserveCompactionPrefix } as Args[3], + ); + return parsed; +} + +test("Anthropic compaction preserves client tool and system cache prefixes and tool choice", () => { + const parsed = applyEffects(true); + expect(parsed.context.tools).toEqual(tools); + expect(parsed.context.systemPrompt).toEqual(["stable system"]); + expect(parsed.options.toolChoice).toBe("auto"); + expect(parsed.options.parallelToolCalls).toBe(false); + expect(parsed._webSearch).toBeUndefined(); + expect(parsed.context.messages.at(-1)?.content).toContain("Do not call tools"); +}); + +test("hosted server declarations stay cacheable but their execution is explicitly prohibited", () => { + const parsed = applyEffects(true, true); + expect(parsed._anthropicServerTools).toEqual([{ type: "web_search_20250305", name: "web_search" }]); + expect(parsed._summaryProhibitsToolCalls).toBe(true); + expect(parsed._webSearch).toBeUndefined(); +}); + +for (const choice of ["required", { type: "function", name: "read" }]) test(`a mandatory tool choice cannot force the summary into a tool call: ${JSON.stringify(choice)}`, () => { + const parsed = applyEffects(true, false, choice); + expect(parsed.context.tools).toEqual(tools); + expect(parsed._summaryProhibitsToolCalls).toBe(true); +}); + +test("other adapters retain the existing compaction deletion behavior", () => { + const parsed = applyEffects(false, true); + expect(parsed.context.tools).toBeUndefined(); + expect(parsed.options.toolChoice).toBeUndefined(); + expect(parsed.options.parallelToolCalls).toBeUndefined(); + expect(parsed._anthropicServerTools).toBeUndefined(); + expect(parsed._webSearch).toBeUndefined(); +}); + +test("summary preservation follows the resolved adapter rather than the provider label", async () => { + type Args = Parameters; + for (const name of ["anthropic", "openai-chat"]) { + const budget = createTranslatorBudget(); + try { + const parsed = { modelId: "fixture", _compactionRequest: true, + context: { tools, messages: [], systemPrompt: [] }, options: {} } as unknown as OcxParsedRequest; + const provider = { adapter: name === "anthropic" ? "openai-chat" : "anthropic", baseUrl: "https://fixture.invalid", authMode: "key" }; + const state = await prepareResponsesSidecarAuth({ options: {}, req: new Request("http://localhost/v1/responses"), + config: { providers: { custom: provider } } } as Args[0], + { parsed, route: { providerName: "custom", modelId: "fixture", provider }, translatorBudget: budget } as Args[1], + { adapter: { name }, isPassthrough: false } as Args[2]); + expect(state.routedCompaction).toBe(true); + expect(state.preserveCompactionPrefix).toBe(name === "anthropic"); + } finally { budget.dispose(); } + } +}); + +test("hosted tools keep the wire prefix with tool_choice none, prohibiting upstream execution", async () => { + const provider = { adapter: "anthropic", baseUrl: "https://api.anthropic.com", authMode: "key" as const, apiKey: "synthetic-test-key" }; + const adapter = withTestTranslatorBudget(createAnthropicAdapter(provider, "short")); + const parsed = applyEffects(true, true); + parsed.modelId = "claude-sonnet-4-6"; + parsed.context.messages.unshift({ role: "user", content: "earlier turn", timestamp: 1 }); + const ordinary = structuredClone(parsed); + ordinary.options.toolChoice = "auto"; + const before = JSON.parse((await adapter.buildRequest(ordinary)).body as string); + const after = JSON.parse((await adapter.buildRequest(parsed)).body as string); + expect(after.tools).toEqual(before.tools); + expect(after.system).toEqual(before.system); + expect(after.tools).toContainEqual(expect.objectContaining({ type: "web_search_20250305", name: "web_search" })); + expect(after.tool_choice).toEqual({ type: "none" }); +}); + +for (const [label, modelId, choice] of [ + ["required allowed tools", "claude-sonnet-4-6", { allowedTools: ["read"], mode: "required" }], + ["named tool on a model that rejects forced choice", "claude-opus-5-5", { type: "function", name: "read" }], +] as const) test(`a narrowed catalog keeps the same tools and system after prohibiting calls: ${label}`, async () => { + const provider = { adapter: "anthropic", baseUrl: "https://api.anthropic.com", authMode: "key" as const, apiKey: "synthetic-test-key" }; + const adapter = withTestTranslatorBudget(createAnthropicAdapter(provider, "short")); + const catalog = [...tools, { name: "write", description: "Write a file", parameters: { type: "object", properties: {} } }]; + const ordinary = { modelId, options: { toolChoice: structuredClone(choice), parallelToolCalls: false }, + context: { tools: structuredClone(catalog), systemPrompt: ["stable system"], + messages: [{ role: "user", content: "earlier turn", timestamp: 1 }] } } as unknown as OcxParsedRequest; + const summary = structuredClone(ordinary); + type Args = Parameters; + createResponsesEffects( + { options: {}, req: new Request("http://localhost/v1/responses"), config: {} } as Args[0], + {} as Args[1], { parsed: summary, route: { provider: {} } } as Args[2], + { routedCompaction: true, preserveCompactionPrefix: true } as Args[3], + ); + expect(summary._summaryProhibitsToolCalls).toBe(true); + const before = JSON.parse((await adapter.buildRequest(ordinary)).body as string); + const after = JSON.parse((await adapter.buildRequest(summary)).body as string); + expect(before.tools.map((tool: { name: string }) => tool.name)).toEqual(["read"]); + expect(after.tools).toEqual(before.tools); + expect(after.system).toEqual(before.system); +}); + +for (const [label, modelId, functions, choice] of [ + ["an excluded same-named function still suppresses the hosted tool", "claude-sonnet-4-6", ["read", "web_search"], { allowedTools: ["read"], mode: "auto" }], + ["a forced-choice downgrade still drops the hosted tool", "claude-opus-5-5", ["read", "write"], { type: "function", name: "read" }], +] as const) test(`hosted declarations keep the ordinary tool prefix: ${label}`, async () => { + const provider = { adapter: "anthropic", baseUrl: "https://api.anthropic.com", authMode: "key" as const, apiKey: "synthetic-test-key" }; + const adapter = withTestTranslatorBudget(createAnthropicAdapter(provider, "short")); + const ordinary = { modelId, options: { toolChoice: structuredClone(choice), parallelToolCalls: false }, + _anthropicServerTools: [{ type: "web_search_20250305", name: "web_search" }], + context: { tools: functions.map(name => ({ name, description: name, parameters: { type: "object", properties: {} } })), + systemPrompt: ["stable system"], messages: [{ role: "user", content: "earlier turn", timestamp: 1 }] } } as unknown as OcxParsedRequest; + const summary = structuredClone(ordinary); + type Args = Parameters; + createResponsesEffects( + { options: {}, req: new Request("http://localhost/v1/responses"), config: {} } as Args[0], + {} as Args[1], { parsed: summary, route: { provider: {} } } as Args[2], + { routedCompaction: true, preserveCompactionPrefix: true } as Args[3], + ); + expect(summary._summaryProhibitsToolCalls).toBe(true); + const before = JSON.parse((await adapter.buildRequest(ordinary)).body as string); + const after = JSON.parse((await adapter.buildRequest(summary)).body as string); + expect(before.tools.map((tool: { name: string }) => tool.name)).toEqual(["read"]); + expect(after.tools).toEqual(before.tools); + expect(after.system).toEqual(before.system); + expect(after.tool_choice).toEqual({ type: "none" }); +}); + +for (const [label, auth, escape, hosted] of [ + ["OAuth renames a hosted web_fetch selector", "oauth", false, { type: "web_fetch_20250910", name: "web_fetch" }], + ["escaped built-in names rename a hosted web_search selector", "key", true, { type: "web_search_20250305", name: "web_search" }], +] as const) test(`provider wire-name transforms keep the ordinary tool prefix: ${label}`, async () => { + const provider = { adapter: "anthropic", baseUrl: "https://api.anthropic.com", authMode: auth, apiKey: "synthetic-test-key", + ...(auth === "oauth" ? { oauthToken: "synthetic-oauth-token" } : {}), ...(escape ? { escapeBuiltinToolNames: true } : {}) }; + const adapter = withTestTranslatorBudget(createAnthropicAdapter(provider as never, "short")); + const ordinary = { modelId: "claude-opus-5-5", options: { toolChoice: { type: "function", name: hosted.name }, parallelToolCalls: false }, + _anthropicServerTools: [hosted], + context: { tools: structuredClone(tools), systemPrompt: ["stable system"], messages: [{ role: "user", content: "earlier turn", timestamp: 1 }] } } as unknown as OcxParsedRequest; + const summary = structuredClone(ordinary); + type Args = Parameters; + createResponsesEffects( + { options: {}, req: new Request("http://localhost/v1/responses"), config: {} } as Args[0], + {} as Args[1], { parsed: summary, route: { provider: {} } } as Args[2], + { routedCompaction: true, preserveCompactionPrefix: true } as Args[3], + ); + const before = JSON.parse((await adapter.buildRequest(ordinary)).body as string); + const after = JSON.parse((await adapter.buildRequest(summary)).body as string); + expect(after.tools).toEqual(before.tools); + expect(after.system).toEqual(before.system); + expect(after.tool_choice).toEqual({ type: "none" }); +}); + +for (const kind of ["text", "tool", "search", "empty", "progress", "abnormal", "eof", "truncated", "error", "incomplete"] as const) { + test(`summary guard validates completion and releases discarded event leases: ${kind}`, async () => { + const budget = createTranslatorBudget(); + budget.chargeRetained(17, { kind: "retained_collectors" }); + const input: AdapterEvent[] = []; + if (kind === "tool") input.push({ type: "tool_call_start", id: "call-summary", name: "read" }, + { type: "tool_call_delta", arguments: "{}" }, { type: "tool_call_end" }); + if (kind === "search") input.push({ type: "web_search_call_begin", id: "srvtoolu-summary" }, + { type: "web_search_call_end", id: "srvtoolu-summary", queries: ["synthetic"] }); + if (kind !== "empty") input.push({ type: "text_delta", text: "summary", + ...(kind === "progress" ? { anthropicThinkingSignature: "synthetic-signature", phase: "commentary" as const } : {}) }); + input.push(kind === "error" ? { type: "error", message: "upstream refused" } + : kind === "incomplete" ? { type: "incomplete", reason: "content_filter", retryable: false } + : { type: "done", stopReason: kind === "eof" ? undefined : kind === "abnormal" ? "unexpected_stop" : kind === "truncated" ? "max_tokens" : "end_turn", endTurn: true }); + try { + const output: AdapterEvent[] = []; + async function* source() { + for (const event of input) { retainTranslatedEvent(event, budget); yield event; } + } + for await (const event of guardCompactionSummary(source(), budget)) { + output.push(event); + releaseTranslatedEvent(event, budget); + } + expect(output.some(event => event.type.startsWith("tool_call") || event.type.startsWith("web_search_call"))).toBe(false); + if (["text", "eof", "truncated", "error", "incomplete"].includes(kind)) expect(output.at(-1)?.type).toBe(input.at(-1)?.type); + else expect(output.at(-1)).toMatchObject({ type: "error", code: "compaction_invalid_summary", retryable: false }); + if (kind === "progress") expect(output.some(event => event.type === "text_delta")).toBe(false); + expect(budget.snapshot().currentBytes).toBe(17); + } finally { budget.dispose(); } + }); +} + +describe("Anthropic compaction through Responses delivery", () => { + const originalFetch = globalThis.fetch; + const originalHome = process.env.OPENCODEX_HOME; + let home: string; + let releaseSpendHome: () => void; + installCompactionRoutingAclFixture(); + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-compaction-prefix-")); + process.env.OPENCODEX_HOME = home; + releaseSpendHome = acquireOwnedSpendHome(); + }); + afterEach(async () => { + await drainCompactionResponseState(); + releaseSpendHome(); + await removeCompactionFixture(home); + globalThis.fetch = originalFetch; + if (originalHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = originalHome; + }); + + function config(adapter = "anthropic", modelAdapter?: string): OcxConfig { + return { defaultProvider: "custom", cacheRetention: "short", providers: { + custom: { adapter, baseUrl: "https://api.anthropic.com", authMode: "key", apiKey: "synthetic-test-key", + ...(modelAdapter ? { modelAdapters: { "claude-sonnet-4-6": modelAdapter } } : {}), + }, + } } as OcxConfig; + } + function body(stream: boolean, compact = true) { + return { model: "custom/claude-sonnet-4-6", stream, store: false, instructions: "stable system", + tools: [{ type: "function", ...tools[0] }], tool_choice: "auto", parallel_tool_calls: false, + input: [{ type: "message", role: "user", content: "earlier turn" }, ...(compact ? [{ type: "compaction_trigger" }] : [])], + }; + } + async function send(input: ReturnType, cfg = config(), options: HandleResponsesOptions = {}) { + return handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(input), + }), cfg, { model: "", provider: "" }, options); + } + function upstream(stream: boolean, tool = false): Response { + if (!stream) return Response.json({ id: "msg-summary", type: "message", role: "assistant", model: "claude-sonnet-4-6", + content: [{ type: "text", text: "handoff summary" }, ...(tool ? [{ type: "tool_use", id: "call-summary", name: "read", input: {} }] : [])], + stop_reason: tool ? "tool_use" : "end_turn", usage: { input_tokens: 10, output_tokens: 5 }, + }); + const events = [ + { type: "message_start", message: { id: "msg-summary", type: "message", role: "assistant", model: "claude-sonnet-4-6", content: [], usage: { input_tokens: 10, output_tokens: 0 } } }, + { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "handoff summary" } }, + { type: "content_block_stop", index: 0 }, + ...(tool ? [ + { type: "content_block_start", index: 1, content_block: { type: "tool_use", id: "call-summary", name: "read", input: {} } }, + { type: "content_block_delta", index: 1, delta: { type: "input_json_delta", partial_json: "{}" } }, + { type: "content_block_stop", index: 1 }, + ] : []), + { type: "message_delta", delta: { stop_reason: tool ? "tool_use" : "end_turn" }, usage: { output_tokens: 5 } }, + { type: "message_stop" }, + ]; + return new Response(events.map(event => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join(""), { + headers: { "content-type": "text/event-stream" }, + }); + } + + for (const stream of [false, true]) { + test(`ordinary and summary requests keep identical tools/system/options (stream=${stream})`, async () => { + const seen: Record[] = []; + globalThis.fetch = (async (_input, init) => { + seen.push(JSON.parse(String(init?.body))); + return upstream(stream); + }) as typeof fetch; + await (await send(body(stream, false))).text(); + const response = await send(body(stream)); + const text = await response.text(); + expect(seen).toHaveLength(2); + for (const key of ["tools", "system", "tool_choice"]) { + expect(seen[1]?.[key]).toEqual(seen[0]?.[key]); + expect(seen[1]?.[key]).toBeDefined(); + } + const json = stream + ? JSON.parse(text.split("\n").find(line => line.startsWith("data: ") && line.includes('"type":"response.completed"'))!.slice(6)).response + : JSON.parse(text); + expect(json.status).toBe("completed"); + expect(json.output).toHaveLength(1); + expect(json.output[0].type).toBe("compaction"); + expect(decodeCompactionSummary(json.output[0].encrypted_content)).toBe("handoff summary"); + }); + + test(`summary client tool output is rejected before publication or execution (stream=${stream})`, async () => { + let sends = 0; + globalThis.fetch = (async () => { sends++; return upstream(stream, true); }) as typeof fetch; + const response = await send(body(stream)); + const text = await response.text(); + expect(sends).toBe(1); + expect(text).not.toContain('"type":"function_call"'); + expect(text).not.toContain('"type":"compaction"'); + expect(text).toContain("compaction_invalid_summary"); + if (stream) expect(text).toContain('"type":"response.failed"'); + else expect(JSON.parse(text).status).toBe("failed"); + }); + } + + test("hosted search is prohibited upstream and the configured search sidecar never executes", async () => { + const cfg = config(); + cfg.webSearchSidecar = { backend: "exa", exaApiKey: "synthetic-search-key" }; + let primarySends = 0; + let hostedExecutions = 0; + let sidecarSends = 0; + globalThis.fetch = (async (input, init) => { + if (!String(input).startsWith("https://api.anthropic.com/")) { + sidecarSends++; + throw new Error("Summary must not dispatch a search sidecar"); + } + primarySends++; + const wire = JSON.parse(String(init?.body)); + expect(wire.tools).toContainEqual(expect.objectContaining({ type: "web_search_20250305", name: "web_search" })); + if (wire.tool_choice?.type !== "none") hostedExecutions++; + return upstream(false); + }) as typeof fetch; + const request = { ...body(false), tools: [...body(false).tools, { type: "web_search", name: "web_search", description: "Search", parameters: {} }] }; + const response = await send(request, cfg, { inboundWire: "anthropic", + anthropicServerTools: [{ type: "web_search_20250305", name: "web_search" }] }); + const result = await response.json(); + expect(result).toMatchObject({ status: "completed" }); + expect(result.output).toHaveLength(1); + expect(result.output[0].type).toBe("compaction"); + expect(primarySends).toBe(1); + expect(hostedExecutions).toBe(0); + expect(sidecarSends).toBe(0); + }); + + test("an Anthropic provider resolved onto Chat still deletes the compaction tool catalog", async () => { + let sent: Record = {}; + globalThis.fetch = (async (_input, init) => { + sent = JSON.parse(String(init?.body)); + return Response.json({ choices: [{ message: { role: "assistant", content: "handoff summary" }, finish_reason: "stop" }], + usage: { prompt_tokens: 10, completion_tokens: 5 } }); + }) as typeof fetch; + const response = await send(body(false), config("anthropic", "openai-chat")); + expect((await response.json()).output).toHaveLength(1); + expect(sent.tools).toBeUndefined(); + expect(sent.tool_choice).toBeUndefined(); + expect(sent.parallel_tool_calls).toBeUndefined(); + }); +}); From 609ce905e9f33af1b57f696acb1bb2b392166c65 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 26 Oct 2026 21:05:46 +0900 Subject: [PATCH 088/101] fix(minimax): move to GA MiniMax-M3.1-Flash and rename the retired preview id (#7746) Carries #7739 onto dev. MiniMax-M3.1-Flash (GA 2026-10-19) becomes the default on both MiniMax presets with refreshed metadata; saved MiniMax-M3.1-Flash-Preview selections are renamed to the GA id at startup, roster repair seeds the 524,288 output limit, and Flash input stays text/image like M3. Co-authored-by: octo-patch <266937838+octo-patch@users.noreply.github.com> --- docs-site/src/content/docs/guides/minimax.md | 2 +- .../src/content/docs/guides/providers.md | 20 +++++----- .../src/content/docs/zh-cn/guides/minimax.md | 2 +- scripts/model-metadata.source.json | 30 +++++++++++---- src/codex/catalog/model-hints.ts | 8 ++-- src/generated/model-metadata.ts | 2 +- src/providers/model-rename-migration.ts | 10 +++++ src/providers/registry/entries-extended.ts | 16 ++++---- src/providers/registry/model-seeds.ts | 23 ++++++----- src/providers/stale-model-roster-migration.ts | 12 +++--- structure/providers-and-adapters.md | 2 +- .../minimax-combo-image-input.test.ts | 2 +- .../providers/minimax-reasoning-split.test.ts | 4 +- .../model-roster-seed-repair.test.ts | 38 ++++++++++++++++++- .../provider-registry-parity.test.ts | 14 +++---- 15 files changed, 124 insertions(+), 61 deletions(-) diff --git a/docs-site/src/content/docs/guides/minimax.md b/docs-site/src/content/docs/guides/minimax.md index db01d782d7a..121bf88c9c1 100644 --- a/docs-site/src/content/docs/guides/minimax.md +++ b/docs-site/src/content/docs/guides/minimax.md @@ -13,7 +13,7 @@ the protocol boundary it actually exposes: ## MiniMax Code In the OpenCodex Models page, both MiniMax Coding Plan regions advertise image input for -`MiniMax-M3` and `MiniMax-M3.1-Flash-Preview`. A Combo's **Image / multimodal** switch is +`MiniMax-M3` and `MiniMax-M3.1-Flash`. A Combo's **Image / multimodal** switch is available only when every selected model supports image input. The switch controls image attachments; it does not enable video uploads. diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 8032797cb0f..c365b6c573b 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -891,17 +891,15 @@ attempt fails, while transient failures keep it. An unreadable or oversized body further attempts. Dollar credits show the included allowance only, excluding purchased credits, so an exhausted allowance stops routing to Ollama Cloud only when no purchased credit remains. -`MiniMax-M3.1-Flash-Preview` (1M context) is listed on both MiniMax presets. MiniMax serves it -only to Token Plan subscription keys and MiniMax Code for now, so a pay-as-you-go API key gets an -error for it. Thinking is always on: the effort picker offers `low` through `max` and defaults to -`max`, and there is no way to turn thinking off. MiniMax has not published a per-token price -for the preview; usage is drawn from your -[Token Plan quota](https://platform.minimax.io/docs/guides/pricing-token-plan), so OpenCodex -shows no estimated cost for it. MiniMax's `/models` endpoint does not list -the preview yet, so OpenCodex keeps it in the catalog from the preset. An install whose saved -MiniMax model list is still the previous default receives it on the next start; a list you edited -is left as it is; register the preview by hand with -`ocx models add minimax MiniMax-M3.1-Flash-Preview --context-window 1000000`. +`MiniMax-M3.1-Flash` (1M context, up to 524,288 output tokens) is the default model on both MiniMax +presets. MiniMax released it on 2026-10-19 and retired the `MiniMax-M3.1-Flash-Preview` id, so a +saved configuration that still names the preview is renamed to `MiniMax-M3.1-Flash` on the next +start. Thinking is always on: the effort picker offers `low` through `max` and defaults to `max`, +and there is no way to turn thinking off. MiniMax's `/models` endpoint does not list Flash, so +OpenCodex keeps it in the catalog from the preset. An install whose saved MiniMax model list is +still the previous default receives it on the next start; a list you edited is left as it is; +register it by hand with +`ocx models add minimax MiniMax-M3.1-Flash --context-window 1000000`. **OpenCode Go** routes `gemini-4-argon-contributor` through its Gemini API, including streaming and tool-result continuations. The existing `opencode-go` preset keeps its diff --git a/docs-site/src/content/docs/zh-cn/guides/minimax.md b/docs-site/src/content/docs/zh-cn/guides/minimax.md index d7baff5ceeb..ca0b375156e 100644 --- a/docs-site/src/content/docs/zh-cn/guides/minimax.md +++ b/docs-site/src/content/docs/zh-cn/guides/minimax.md @@ -11,7 +11,7 @@ MiniMax 发布两种不同的命令行产品。OpenCodex 在它们实际提供 ## MiniMax Code 在 OpenCodex 的模型管理页面,MiniMax Coding Plan 两个区域的 `MiniMax-M3` 和 -`MiniMax-M3.1-Flash-Preview` 均声明支持图片输入。只有所有选定模型均支持图片输入时, +`MiniMax-M3.1-Flash` 均声明支持图片输入。只有所有选定模型均支持图片输入时, Combo 的“图片 / 多模态”开关才可用。该开关控制图片附件,不提供视频上传能力。 先按照 MiniMax 的说明安装并登录 MiniMax Code,然后启动 OpenCodex 并连接可逆的文件集成: diff --git a/scripts/model-metadata.source.json b/scripts/model-metadata.source.json index 4c075688b60..9e6b6adc015 100644 --- a/scripts/model-metadata.source.json +++ b/scripts/model-metadata.source.json @@ -42160,9 +42160,9 @@ "maxLevel": "xhigh" } }, - "MiniMax-M3.1-Flash-Preview": { - "id": "MiniMax-M3.1-Flash-Preview", - "name": "MiniMax-M3.1-Flash-Preview", + "MiniMax-M3.1-Flash": { + "id": "MiniMax-M3.1-Flash", + "name": "MiniMax-M3.1-Flash", "api": "anthropic-messages", "provider": "minimax", "baseUrl": "https://api.minimax.io/anthropic", @@ -42172,7 +42172,14 @@ "image", "video" ], - "contextWindow": 1000000 + "contextWindow": 1000000, + "maxTokens": 524288, + "cost": { + "input": 0.15, + "output": 0.6, + "cacheRead": 0.015, + "cacheWrite": 0 + } } }, "minimax-cn": { @@ -42394,9 +42401,9 @@ "maxLevel": "xhigh" } }, - "MiniMax-M3.1-Flash-Preview": { - "id": "MiniMax-M3.1-Flash-Preview", - "name": "MiniMax-M3.1-Flash-Preview", + "MiniMax-M3.1-Flash": { + "id": "MiniMax-M3.1-Flash", + "name": "MiniMax-M3.1-Flash", "api": "anthropic-messages", "provider": "minimax-cn", "baseUrl": "https://api.minimaxi.com/anthropic", @@ -42406,7 +42413,14 @@ "image", "video" ], - "contextWindow": 1000000 + "contextWindow": 1000000, + "maxTokens": 524288, + "cost": { + "input": 0.15, + "output": 0.6, + "cacheRead": 0.015, + "cacheWrite": 0 + } } }, "minimax-code": { diff --git a/src/codex/catalog/model-hints.ts b/src/codex/catalog/model-hints.ts index c2bad364722..15b1a09e900 100644 --- a/src/codex/catalog/model-hints.ts +++ b/src/codex/catalog/model-hints.ts @@ -470,13 +470,13 @@ export const CALLABLE_CONFIGURED_COMPATIBILITY_MODELS: Readonly = { "cerebras": [["gemma-4-31b",131072,40960,"text,image",1,null,0.99,1.49,0,0],["gpt-oss-120b",131072,40960,"text",1,null,0.35,0.75,0,0],["llama3.1-8b",32000,8000,"text",0,null,0.1,0.1,0,0],["qwen-3-235b-a22b-instruct-2507",131000,32000,"text",0,null,0.6,1.2,0,0],["qwen-3-coder-480b",131072,32768,"text",0,null,0,0,0,0],["zai-glm-4.6",131072,32768,"text",0,null,0,0,0,0],["zai-glm-4.7",131072,40960,"text",1,null,2.25,2.75,2.25,0]], "deepseek": [["deepseek-v4-flash",1048576,384000,"text",1,null,0.14,0.28,0.0028,0],["deepseek-v4-pro",1048576,384000,"text",1,null,0.435,0.87,0.003625,0]], "google": [["deep-research-max-preview-04-2026",131072,65536,"text,image",1,null,2,12,0.2,0],["deep-research-preview-04-2026",131072,65536,"text,image",1,null,2,12,0.2,0],["gemini-1.5-flash",1000000,8192,"text,image",0,null,0.075,0.3,0.01875,0],["gemini-1.5-flash-8b",1000000,8192,"text,image",0,null,0.0375,0.15,0.01,0],["gemini-1.5-pro",1000000,8192,"text,image",0,null,1.25,5,0.3125,0],["gemini-2.0-flash",1048576,8192,"text,image",0,null,0.1,0.4,0.025,0],["gemini-2.0-flash-lite",1048576,8192,"text,image",0,null,0.075,0.3,0,0],["gemini-2.5-computer-use-preview-10-2025",131072,65536,"text,image",1,null,1.25,10,0,0],["gemini-2.5-flash",1048576,65536,"text,image",1,null,0.3,2.5,0.03,0],["gemini-2.5-flash-lite",1048576,65536,"text,image",1,null,0.1,0.4,0.01,0],["gemini-2.5-flash-lite-preview-06-17",1048576,65536,"text,image",1,null,0.1,0.4,0.025,0],["gemini-2.5-flash-lite-preview-09-2025",1048576,65536,"text,image",1,null,0.1,0.4,0.025,0],["gemini-2.5-flash-preview-04-17",1048576,65536,"text,image",1,null,0.15,0.6,0.0375,0],["gemini-2.5-flash-preview-05-20",1048576,65536,"text,image",1,null,0.15,0.6,0.0375,0],["gemini-2.5-flash-preview-09-2025",1048576,65536,"text,image",1,null,0.3,2.5,0.075,0],["gemini-2.5-pro",1048576,65536,"text,image",1,null,1.25,10,0.125,0],["gemini-2.5-pro-preview-05-06",1048576,65536,"text,image",1,null,1.25,10,0.31,0],["gemini-2.5-pro-preview-06-05",1048576,65536,"text,image",1,null,1.25,10,0.31,0],["gemini-3-flash-preview",1048576,65536,"text,image",1,null,0.5,3,0.05,0],["gemini-3-pro-preview",1048576,65536,"text,image",1,null,2,12,0.2,0],["gemini-3.1-flash-lite",1048576,65536,"text,image",1,null,0.25,1.5,0.025,0],["gemini-3.1-flash-lite-image",65536,65536,"text,image",1,null,0.25,30,0,0],["gemini-3.1-flash-lite-preview",1048576,65536,"text,image",1,null,0.25,1.5,0.025,0],["gemini-3.1-flash-live-preview",131072,65536,"text,image",1,null,0.75,4.5,0,0],["gemini-3.1-pro-preview",1048576,65536,"text,image",1,null,2,12,0.2,0],["gemini-3.1-pro-preview-customtools",1048576,65536,"text,image",1,null,2,12,0.2,0],["gemini-3.5-flash",1048576,65536,"text,image",1,null,1.5,9,0.15,0],["gemini-3.5-flash-lite",1048576,65536,"text,image",1,null,0.3,2.5,0.03,0],["gemini-3.6-flash",1048576,65536,"text,image",1,null,1.5,7.5,0.15,0],["gemini-3.7-flash",1048576,65536,"text,image",1],["gemini-3.8-flash",1048576,65536,"text,image",1],["gemini-4-argon",1048576,65536,"text,image",1],["gemini-flash-latest",1048576,65536,"text,image",1,null,1.5,9,0.15,0],["gemini-flash-lite-latest",1048576,65536,"text,image",1,null,0.25,1.5,0.025,0],["gemini-live-2.5-flash",128000,8000,"text,image",1,null,0.5,2,0,0],["gemini-live-2.5-flash-preview-native-audio",131072,65536,"text",1,null,0.5,2,0,0],["gemini-robotics-er-1.6-preview",131072,65536,"text,image",1,null,1,5,0,0],["gemma-3-27b-it",131072,8192,"text,image",0,null,0,0,0,0],["gemma-4-26b",256000,8192,"text,image",1,null,0,0,0,0],["gemma-4-26b-a4b-it",262144,32768,"text,image",1,null,0,0,0,0],["gemma-4-26b-it",256000,8192,"text,image",1,null,0,0,0,0],["gemma-4-31b",256000,8192,"text,image",1,null,0,0,0,0],["gemma-4-31b-it",262144,32768,"text,image",1,null,0,0,0,0],["gemma-4-E2B-it",131072,8192,"text,image",1,null,0,0,0,0],["gemma-4-E4B-it",131072,8192,"text,image",1,null,0,0,0,0]], - "minimax": [["MiniMax-M2",196608,128000,"text",1,null,0.3,1.2,0,0],["MiniMax-M2.1",204800,131072,"text",1,null,0.3,1.2,0,0],["MiniMax-M2.5",204800,131072,"text",1,null,0.3,1.2,0.03,0.375],["MiniMax-M2.5-highspeed",204800,131072,"text",1,null,0.6,2.4,0.06,0.375],["MiniMax-M2.5-lightning",204800,32000,"text",1,null,0.3,2.4,0,0],["MiniMax-M2.7",204800,131072,"text",1,null,0.3,1.2,0.06,0.375],["MiniMax-M2.7-highspeed",204800,131072,"text",1,null,0.6,2.4,0.06,0.375],["minimax-m3",512000,128000,"text,image",1,null,0.6,2.4,0.12,0],["MiniMax-M3",1000000,128000,"text,image,video",1,null,0.3,1.2,0.06,0],["MiniMax-M3.1-Flash-Preview",1000000,null,"text,image,video",1]], + "minimax": [["MiniMax-M2",196608,128000,"text",1,null,0.3,1.2,0,0],["MiniMax-M2.1",204800,131072,"text",1,null,0.3,1.2,0,0],["MiniMax-M2.5",204800,131072,"text",1,null,0.3,1.2,0.03,0.375],["MiniMax-M2.5-highspeed",204800,131072,"text",1,null,0.6,2.4,0.06,0.375],["MiniMax-M2.5-lightning",204800,32000,"text",1,null,0.3,2.4,0,0],["MiniMax-M2.7",204800,131072,"text",1,null,0.3,1.2,0.06,0.375],["MiniMax-M2.7-highspeed",204800,131072,"text",1,null,0.6,2.4,0.06,0.375],["minimax-m3",512000,128000,"text,image",1,null,0.6,2.4,0.12,0],["MiniMax-M3",1000000,128000,"text,image,video",1,null,0.3,1.2,0.06,0],["MiniMax-M3.1-Flash",1000000,524288,"text,image,video",1,null,0.15,0.6,0.015,0]], "mistral": [["codestral-latest",256000,4096,"text",0,null,0.3,0.9,0,0],["devstral-2512",262144,262144,"text",0,null,0.4,2,0,0],["devstral-latest",262144,262144,"text",0,null,0.4,2,0,0],["devstral-medium-2507",128000,128000,"text",0,null,0.4,2,0,0],["devstral-medium-latest",262144,262144,"text",0,null,0.4,2,0,0],["devstral-small-2505",128000,128000,"text",0,null,0.1,0.3,0,0],["devstral-small-2507",128000,128000,"text",0,null,0.1,0.3,0,0],["labs-devstral-small-2512",256000,256000,"text,image",0,null,0,0,0,0],["magistral-medium-latest",128000,16384,"text",1,null,2,5,0,0],["magistral-small",128000,128000,"text",1,null,0.5,1.5,0,0],["ministral-3b-latest",128000,128000,"text",0,null,0.04,0.04,0,0],["ministral-8b-latest",128000,128000,"text",0,null,0.1,0.1,0,0],["mistral-large-2411",131072,16384,"text",0,null,2,6,0,0],["mistral-large-2512",262144,262144,"text,image",0,null,0.5,1.5,0,0],["mistral-large-latest",262144,262144,"text,image",0,null,0.5,1.5,0,0],["mistral-medium-2505",131072,131072,"text,image",0,null,0.4,2,0,0],["mistral-medium-2508",262144,262144,"text,image",0,null,0.4,2,0,0],["mistral-medium-2604",262144,262144,"text,image",1,null,1.5,7.5,0,0],["mistral-medium-latest",262144,262144,"text,image",1,null,1.5,7.5,0,0],["mistral-nemo",128000,128000,"text",0,null,0.15,0.15,0,0],["mistral-small-2506",128000,16384,"text,image",0,null,0.1,0.3,0,0],["mistral-small-2603",256000,256000,"text,image",1,null,0.15,0.6,0,0],["mistral-small-latest",256000,256000,"text,image",1,null,0.15,0.6,0,0],["open-mistral-7b",8000,8000,"text",0,null,0.25,0.25,0,0],["open-mistral-nemo",128000,128000,"text",0,null,0.15,0.15,0,0],["open-mixtral-8x22b",64000,64000,"text",0,null,2,6,0,0],["open-mixtral-8x7b",32000,32000,"text",0,null,0.7,0.7,0,0],["pixtral-12b",128000,128000,"text,image",0,null,0.15,0.15,0,0],["pixtral-large-latest",128000,128000,"text,image",0,null,2,6,0,0]], "moonshot": [["kimi-k2.5",262144,65536,"text,image",1,null,0,0,0,0]], "openai": [["codex-mini-latest",200000,100000,"text",1,null,1.5,6,0.375,0],["gpt-4",8192,8192,"text",0,null,30,60,0,0],["gpt-4-turbo",128000,4096,"text,image",0,null,10,30,0,0],["gpt-4.1",1047576,32768,"text,image",0,null,2,8,0.5,0],["gpt-4.1-mini",1047576,32768,"text,image",0,null,0.4,1.6,0.1,0],["gpt-4.1-nano",1047576,32768,"text,image",0,null,0.1,0.4,0.025,0],["gpt-4o",128000,16384,"text,image",0,null,2.5,10,1.25,0],["gpt-4o-2024-05-13",128000,4096,"text,image",0,null,5,15,0,0],["gpt-4o-2024-08-06",128000,16384,"text,image",0,null,2.5,10,1.25,0],["gpt-4o-2024-11-20",128000,16384,"text,image",0,null,2.5,10,1.25,0],["gpt-4o-mini",128000,16384,"text,image",0,null,0.15,0.6,0.075,0],["gpt-5",400000,128000,"text,image",1,null,1.25,10,0.125,0],["gpt-5-chat-latest",128000,16384,"text,image",0,null,1.25,10,0.125,0],["gpt-5-codex",272000,128000,"text,image",1,null,1.25,10,0.125,0],["gpt-5-mini",400000,128000,"text,image",1,null,0.25,2,0.025,0],["gpt-5-nano",400000,128000,"text,image",1,null,0.05,0.4,0.005,0],["gpt-5-pro",400000,272000,"text,image",1,null,15,120,0,0],["gpt-5.1",400000,128000,"text,image",1,null,1.25,10,0.125,0],["gpt-5.1-chat-latest",128000,16384,"text,image",1,null,1.25,10,0.125,0],["gpt-5.1-codex",272000,128000,"text,image",1,null,1.25,10,0.125,0],["gpt-5.1-codex-max",272000,128000,"text,image",1,null,1.25,10,0.125,0],["gpt-5.1-codex-mini",272000,128000,"text,image",1,null,0.25,2,0.025,0],["gpt-5.2",400000,128000,"text,image",1,null,1.75,14,0.175,0],["gpt-5.2-chat-latest",128000,16384,"text,image",1,null,1.75,14,0.175,0],["gpt-5.2-codex",272000,128000,"text,image",1,null,1.75,14,0.175,0],["gpt-5.2-pro",400000,128000,"text,image",1,null,21,168,0,0],["gpt-5.3-chat-latest",128000,16384,"text,image",0,null,1.75,14,0.175,0],["gpt-5.3-codex",272000,128000,"text,image",1,null,1.75,14,0.175,0],["gpt-5.3-codex-spark",128000,32000,"text,image",1,null,1.75,14,0.175,0],["gpt-5.4",1050000,128000,"text,image",1,null,2.5,15,0.25,0],["gpt-5.4-mini",400000,128000,"text,image",1,null,0.75,4.5,0.075,0],["gpt-5.4-nano",400000,128000,"text,image",1,null,0.2,1.25,0.02,0],["gpt-5.4-pro",1050000,128000,"text,image",1,null,30,180,0,0],["gpt-5.5",1050000,128000,"text,image",1,null,5,30,0.5,0],["gpt-5.5-pro",1050000,128000,"text,image",1,null,30,180,0,0],["gpt-5.6",1050000,128000,"text,image",1,null,5,30,0.5,6.25],["gpt-5.6-luna",1050000,128000,"text,image",1,null,0.2,1.2,0.02,0.25],["gpt-5.6-sol",1050000,128000,"text,image",1,null,5,30,0.5,6.25],["gpt-5.6-terra",1050000,128000,"text,image",1,null,2,12,0.2,2.5],["gpt-6-astra",1050000,128000,"text,image",1,null,10,50,1,12.5],["gpt-6-luna",1050000,128000,"text,image",1,null,0.1,0.5,0.01,0.125],["gpt-6-sol",1050000,128000,"text,image",1,null,2,10,0.2,2.5],["gpt-6.1-sol",1050000,128000,"text,image",1,null,2,10,0.1,2.5],["gpt-realtime-2.1",128000,32000,"text,image",1,null,4,24,0.4,0],["o1",200000,100000,"text,image",1,null,15,60,7.5,0],["o1-pro",200000,100000,"text,image",1,null,150,600,0,0],["o3",200000,100000,"text,image",1,null,2,8,0.5,0],["o3-deep-research",200000,100000,"text,image",1,null,10,40,2.5,0],["o3-mini",200000,100000,"text",1,null,1.1,4.4,0.55,0],["o3-pro",200000,100000,"text,image",1,null,20,80,0,0],["o4-mini",200000,100000,"text,image",1,null,1.1,4.4,0.275,0],["o4-mini-deep-research",200000,100000,"text,image",1,null,2,8,0.5,0]], diff --git a/src/providers/model-rename-migration.ts b/src/providers/model-rename-migration.ts index 37a778766a1..3b677a6d17d 100644 --- a/src/providers/model-rename-migration.ts +++ b/src/providers/model-rename-migration.ts @@ -19,6 +19,7 @@ // Everything else in the row is left alone. import { PROVIDER_REGISTRY, normalizedProviderEndpoint } from "./registry"; +import { MINIMAX_M31_FLASH, MINIMAX_M31_FLASH_PREVIEW_RETIRED } from "./registry/model-seeds"; import { MODEL_KEYED_RECORDS, MODEL_ID_LISTS, MODEL_ID_SCALARS, MODEL_NESTED_RECORDS } from "./model-rename-fields"; import { providerConfigSeed } from "./derive"; import type { OcxConfig, OcxProviderConfig } from "../types"; @@ -138,6 +139,15 @@ export const MODEL_RENAMES: readonly ModelRename[] = [ // may also hold one as a value. 3.8 resolves tiers from its own suffix ladder instead. dropReasoningEffortMap: true, })), + // MiniMax shipped M3.1-Flash as generally available on 2026-10-19 and retired the preview + // id, so a saved MiniMax-M3.1-Flash-Preview selection would answer model-not-found. The + // stale-roster refresh only matches exact old seeds; this repairs the preview id itself. + ...(["minimax", "minimax-cn"] as const).map(provider => ({ + provider, + from: MINIMAX_M31_FLASH_PREVIEW_RETIRED, + to: MINIMAX_M31_FLASH, + reason: "MiniMax shipped M3.1-Flash as generally available and retired the preview id", + })), ]; function renameInList(value: unknown, from: string, to: string): string[] | null { diff --git a/src/providers/registry/entries-extended.ts b/src/providers/registry/entries-extended.ts index 2d28b7f0756..211b94fe5e7 100644 --- a/src/providers/registry/entries-extended.ts +++ b/src/providers/registry/entries-extended.ts @@ -38,7 +38,7 @@ import { MINIMAX_MODEL_INPUT_MODALITIES, MINIMAX_M3_REASONING_EFFORTS, MINIMAX_M3_REASONING_EFFORT_MAP, - MINIMAX_M31_FLASH_PREVIEW, + MINIMAX_M31_FLASH, MINIMAX_M31_REASONING_EFFORTS, MINIMAX_M31_DEFAULT_REASONING_EFFORT, MINIMAX_REASONING_SPLIT_MODELS, @@ -1044,18 +1044,18 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ }, { id: "minimax", label: "MiniMax — Coding Plan", baseUrl: "https://api.minimax.io/v1", adapter: "openai-chat", authKind: "key", - dashboardUrl: "https://platform.minimax.io", defaultModel: "MiniMax-M3", models: MINIMAX_MODELS, + dashboardUrl: "https://platform.minimax.io", defaultModel: MINIMAX_M31_FLASH, models: MINIMAX_MODELS, modelContextWindows: MINIMAX_MODEL_CONTEXT_WINDOWS, modelInputModalities: MINIMAX_MODEL_INPUT_MODALITIES, - modelReasoningEfforts: { "MiniMax-M3": MINIMAX_M3_REASONING_EFFORTS, [MINIMAX_M31_FLASH_PREVIEW]: MINIMAX_M31_REASONING_EFFORTS }, - modelDefaultReasoningEfforts: { "MiniMax-M3": "medium", [MINIMAX_M31_FLASH_PREVIEW]: MINIMAX_M31_DEFAULT_REASONING_EFFORT }, + modelReasoningEfforts: { "MiniMax-M3": MINIMAX_M3_REASONING_EFFORTS, [MINIMAX_M31_FLASH]: MINIMAX_M31_REASONING_EFFORTS }, + modelDefaultReasoningEfforts: { "MiniMax-M3": "medium", [MINIMAX_M31_FLASH]: MINIMAX_M31_DEFAULT_REASONING_EFFORT }, modelReasoningEffortMap: { "MiniMax-M3": MINIMAX_M3_REASONING_EFFORT_MAP }, preserveReasoningContentModels: MINIMAX_MODELS, // MiniMax-M3 low effort maps to thinking disabled, so a legitimate tool // round can carry no reasoning at all; only replay real recorded text, // never a fabricated placeholder (chatgpt-codex-connector P2 on #1205). requiresReasoningPlaceholderModels: [], - // M3.1-Flash-Preview ignores reasoning_split and always answers with reasoning_content, + // M3.1-Flash ignores reasoning_split and always answers with reasoning_content, // so it stays off the split/details lists and replays as a reasoning_content string. reasoningSplitModels: MINIMAX_REASONING_SPLIT_MODELS, // With reasoning_split the upstream returns thinking as a structured @@ -1070,11 +1070,11 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ }, { id: "minimax-cn", label: "MiniMax — Coding Plan (CN)", baseUrl: "https://api.minimaxi.com/v1", adapter: "openai-chat", authKind: "key", - dashboardUrl: "https://platform.minimaxi.com", defaultModel: "MiniMax-M3", models: MINIMAX_MODELS, + dashboardUrl: "https://platform.minimaxi.com", defaultModel: MINIMAX_M31_FLASH, models: MINIMAX_MODELS, modelContextWindows: MINIMAX_MODEL_CONTEXT_WINDOWS, modelInputModalities: MINIMAX_MODEL_INPUT_MODALITIES, - modelReasoningEfforts: { "MiniMax-M3": MINIMAX_M3_REASONING_EFFORTS, [MINIMAX_M31_FLASH_PREVIEW]: MINIMAX_M31_REASONING_EFFORTS }, - modelDefaultReasoningEfforts: { "MiniMax-M3": "medium", [MINIMAX_M31_FLASH_PREVIEW]: MINIMAX_M31_DEFAULT_REASONING_EFFORT }, + modelReasoningEfforts: { "MiniMax-M3": MINIMAX_M3_REASONING_EFFORTS, [MINIMAX_M31_FLASH]: MINIMAX_M31_REASONING_EFFORTS }, + modelDefaultReasoningEfforts: { "MiniMax-M3": "medium", [MINIMAX_M31_FLASH]: MINIMAX_M31_DEFAULT_REASONING_EFFORT }, modelReasoningEffortMap: { "MiniMax-M3": MINIMAX_M3_REASONING_EFFORT_MAP }, preserveReasoningContentModels: MINIMAX_MODELS, requiresReasoningPlaceholderModels: [], diff --git a/src/providers/registry/model-seeds.ts b/src/providers/registry/model-seeds.ts index 0ef89283bc4..166e90e4752 100644 --- a/src/providers/registry/model-seeds.ts +++ b/src/providers/registry/model-seeds.ts @@ -155,30 +155,33 @@ export const ZAI_GLM_5X_REASONING_EFFORTS: Record = { }; // 260710 MiniMax models and context windows: Tier-2 evidence in // devlog/_plan/260710_provider_hardening/002_research_cn.md. -// 260930 MiniMax-M3.1-Flash-Preview: Token Plan / MiniMax Code only, 1M context, thinking -// always on (effort none or thinking disabled answers 400 code 2013), omitted effort = max. -// It returns thinking as reasoning_content and ignores reasoning_split. The live /v1/models -// roster does not list it yet. Evidence: devlog/_fin/260930_minimax_m31_flash_preview/. -export const MINIMAX_M31_FLASH_PREVIEW = "MiniMax-M3.1-Flash-Preview"; +// 261021 MiniMax-M3.1-Flash: current global/CN text route, 1M context, thinking always on +// (effort none or thinking disabled answers 400), omitted effort = max. It returns thinking as +// reasoning_content and ignores reasoning_split. The live /v1/models roster may omit it. +export const MINIMAX_M31_FLASH = "MiniMax-M3.1-Flash"; +/** The preview id MiniMax retired when M3.1-Flash went GA (2026-10-19); see MODEL_RENAMES. */ +export const MINIMAX_M31_FLASH_PREVIEW_RETIRED = "MiniMax-M3.1-Flash-Preview"; +/** MiniMax's published M3.1-Flash output ceiling (512K). */ +export const MINIMAX_M31_FLASH_MAX_OUTPUT_TOKENS = 524_288; export const MINIMAX_MODELS = [ - MINIMAX_M31_FLASH_PREVIEW, + MINIMAX_M31_FLASH, "MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M2.5", "MiniMax-M2.5-highspeed", "MiniMax-M2.1", "MiniMax-M2.1-highspeed", "MiniMax-M2", ]; -/** The eight-id roster every MiniMax preset seeded from 2026-07-10 until the preview landed. */ -export const MINIMAX_MODELS_BEFORE_M31 = MINIMAX_MODELS.filter(id => id !== MINIMAX_M31_FLASH_PREVIEW); +/** The eight-id roster every MiniMax preset seeded before Flash landed. */ +export const MINIMAX_MODELS_BEFORE_M31 = MINIMAX_MODELS.filter(id => id !== MINIMAX_M31_FLASH); /** Models that honour reasoning_split and answer with structured reasoning_details. */ export const MINIMAX_REASONING_SPLIT_MODELS = MINIMAX_MODELS_BEFORE_M31; export const MINIMAX_MODEL_CONTEXT_WINDOWS: Record = Object.fromEntries( - MINIMAX_MODELS.map(id => [id, id === "MiniMax-M3" || id === MINIMAX_M31_FLASH_PREVIEW ? 1_000_000 : 204_800]), + MINIMAX_MODELS.map(id => [id, id === "MiniMax-M3" || id === MINIMAX_M31_FLASH ? 1_000_000 : 204_800]), ); /** MiniMax's OpenAI-compatible M3 endpoints accept image_url; keep video off Codex's input enum. */ export const MINIMAX_MODEL_INPUT_MODALITIES: Record = { "MiniMax-M3": ["text", "image"], - [MINIMAX_M31_FLASH_PREVIEW]: ["text", "image"], + [MINIMAX_M31_FLASH]: ["text", "image"], }; export const MINIMAX_M3_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"]; /** Identity efforts on the wire; no map, so none omits the field instead of disabling thinking. */ diff --git a/src/providers/stale-model-roster-migration.ts b/src/providers/stale-model-roster-migration.ts index 9ab1b24fc1a..599d8204921 100644 --- a/src/providers/stale-model-roster-migration.ts +++ b/src/providers/stale-model-roster-migration.ts @@ -31,7 +31,8 @@ import { META_MUSE_MODELS, META_MUSE_WONDER_MODEL, MINIMAX_M31_DEFAULT_REASONING_EFFORT, - MINIMAX_M31_FLASH_PREVIEW, + MINIMAX_M31_FLASH, + MINIMAX_M31_FLASH_MAX_OUTPUT_TOKENS, MINIMAX_MODELS, MINIMAX_MODELS_BEFORE_M31, MINIMAX_MODEL_CONTEXT_WINDOWS, @@ -80,8 +81,8 @@ const ALIBABA_INTL_TOKEN_PLAN_MODELS_BEFORE_1017 = [ ]; /** - * MiniMax-M3.1-Flash-Preview (2026-09-30). MiniMax's /v1/models roster does not list the - * preview, so live discovery cannot add it; the catalog keeps it only when it is configured. + * MiniMax-M3.1-Flash (2026-10-21). MiniMax's /v1/models roster does not list the + * Flash, so live discovery cannot add it; the catalog keeps it only when it is configured. */ export const STALE_MODEL_ROSTERS: readonly StaleModelRoster[] = [ { @@ -104,8 +105,9 @@ export const STALE_MODEL_ROSTERS: readonly StaleModelRoster[] = [ provider, from: MINIMAX_MODELS_BEFORE_M31, to: MINIMAX_MODELS, - contextWindows: { [MINIMAX_M31_FLASH_PREVIEW]: MINIMAX_MODEL_CONTEXT_WINDOWS[MINIMAX_M31_FLASH_PREVIEW]! }, - defaultReasoningEfforts: { [MINIMAX_M31_FLASH_PREVIEW]: MINIMAX_M31_DEFAULT_REASONING_EFFORT }, + contextWindows: { [MINIMAX_M31_FLASH]: MINIMAX_MODEL_CONTEXT_WINDOWS[MINIMAX_M31_FLASH]! }, + maxOutputTokens: { [MINIMAX_M31_FLASH]: MINIMAX_M31_FLASH_MAX_OUTPUT_TOKENS }, + defaultReasoningEfforts: { [MINIMAX_M31_FLASH]: MINIMAX_M31_DEFAULT_REASONING_EFFORT }, })), ...[ { provider: "alibaba-token-plan", from: ALIBABA_TOKEN_PLAN_MODELS_BEFORE_1017, to: ALIBABA_TOKEN_PLAN_MODELS }, diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index a29873eb4b0..566cd1bb9eb 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -391,7 +391,7 @@ axis that outranks every source here, so it is where a deliberate text-only over (`ocx provider edit --model --text-only` writes it) and the one declaration a restart cannot take back. -When `/models` omits MiniMax-M3.1-Flash-Preview, `src/providers/stale-model-roster-migration.ts` replaces only a saved roster identical to the old seed and fills existing window and default-effort records during startup; `CALLABLE_CONFIGURED_COMPATIBILITY_MODELS` (`src/codex/catalog/model-hints.ts`) retains it in the live catalog. The same startup projection restores DeepSeek V4 Pro only for the exact previous native seed on the official destination and registry adapter, filling missing Pro context in existing maps. Operator lists (including empty lists), custom destinations and explicit context overrides remain unchanged; normal registry enrichment supplies Pro's reasoning metadata. +When `/models` omits MiniMax-M3.1-Flash, `src/providers/stale-model-roster-migration.ts` replaces only a saved roster identical to the old seed and fills existing window, output-limit and default-effort records during startup; `CALLABLE_CONFIGURED_COMPATIBILITY_MODELS` (`src/codex/catalog/model-hints.ts`) retains it in the live catalog. `MODEL_RENAMES` (`src/providers/model-rename-migration.ts`) repairs saved rows that still name the retired MiniMax-M3.1-Flash-Preview id. The same startup projection restores DeepSeek V4 Pro only for the exact previous native seed on the official destination and registry adapter, filling missing Pro context in existing maps. Operator lists (including empty lists), custom destinations and explicit context overrides remain unchanged; normal registry enrichment supplies Pro's reasoning metadata. Both MiniMax Coding Plan presets declare `text` and `image` for M3 and M3.1 in `src/providers/registry/model-seeds.ts`; registry enrichment fills missing saved declarations, so id-only live rows enable the Combo image switch when every target supports images. Explicit overrides still win; video is outside Codex's catalog input enum. diff --git a/tests/providers/minimax-combo-image-input.test.ts b/tests/providers/minimax-combo-image-input.test.ts index 4ae06470c62..abef19e950f 100644 --- a/tests/providers/minimax-combo-image-input.test.ts +++ b/tests/providers/minimax-combo-image-input.test.ts @@ -9,7 +9,7 @@ import { isModelVisionSidecarConsumer } from "../../src/vision/eligibility"; import type { CatalogModel, OcxConfig, OcxProviderConfig } from "../../src/types"; const M3 = "MiniMax-M3"; -const PREVIEW = "MiniMax-M3.1-Flash-Preview"; +const PREVIEW = "MiniMax-M3.1-Flash"; const REGIONS = ["minimax", "minimax-cn"] as const; const originalFetch = globalThis.fetch; diff --git a/tests/providers/minimax-reasoning-split.test.ts b/tests/providers/minimax-reasoning-split.test.ts index c2decc342b1..62287b58d84 100644 --- a/tests/providers/minimax-reasoning-split.test.ts +++ b/tests/providers/minimax-reasoning-split.test.ts @@ -376,11 +376,11 @@ describe("MiniMax split reasoning", () => { }); }); -describe("MiniMax-M3.1-Flash-Preview reasoning wire", () => { +describe("MiniMax-M3.1-Flash reasoning wire", () => { // Probed 2026-09-30: thinking cannot be turned off (effort none or thinking disabled // answers 400 code 2013), effort low..max is accepted as-is, reasoning_split is ignored // and thinking always returns as reasoning_content. - const PREVIEW = "MiniMax-M3.1-Flash-Preview"; + const PREVIEW = "MiniMax-M3.1-Flash"; test("Codex efforts go out as identity reasoning_effort and never disable thinking", () => { const route = minimaxRoute(PREVIEW); diff --git a/tests/providers/model-roster-seed-repair.test.ts b/tests/providers/model-roster-seed-repair.test.ts index 7d48fc2b229..399958c5693 100644 --- a/tests/providers/model-roster-seed-repair.test.ts +++ b/tests/providers/model-roster-seed-repair.test.ts @@ -16,7 +16,7 @@ import { PROVIDER_REGISTRY } from "../../src/providers/registry"; import { enrichProviderFromRegistry } from "../../src/providers/derive"; import type { OcxConfig, OcxProviderConfig } from "../../src/types"; -const PREVIEW = "MiniMax-M3.1-Flash-Preview"; +const PREVIEW = "MiniMax-M3.1-Flash"; describe("DeepSeek Pro saved roster refresh", () => { const before = ["deepseek-chat", "deepseek-reasoner", "deepseek-flash", "deepseek-v4-flash", "deepseek-v4-flash-vision-exp"]; @@ -140,6 +140,42 @@ describe("stale model roster migration", () => { expect(projection.config.providers!.minimax!.modelDefaultReasoningEfforts).toEqual({ [PREVIEW]: "low" }); }); + test("fills the Flash output limit into an existing partial output map", () => { + // Custom catalog rows read the saved map directly; without this the added id would + // publish with no output ceiling (CodeRabbit on #7739). + const projection = projectStaleModelRosters(minimaxConfig("minimax", { + models: [...MINIMAX_MODELS_BEFORE_M31], + modelMaxOutputTokens: { "MiniMax-M3": 128_000 }, + })); + expect(projection.config.providers!.minimax!.modelMaxOutputTokens).toEqual({ + "MiniMax-M3": 128_000, [PREVIEW]: 524_288, + }); + }); + + test("renames a saved MiniMax-M3.1-Flash-Preview selection to the GA id on both presets", () => { + const RETIRED = "MiniMax-M3.1-Flash-Preview"; + for (const [provider, baseUrl] of [["minimax", "https://api.minimax.io/v1"], ["minimax-cn", "https://api.minimaxi.com/v1"]] as const) { + const config = minimaxConfig(provider, { + baseUrl, + authMode: "key", + defaultModel: RETIRED, + models: MINIMAX_MODELS.map(id => (id === PREVIEW ? RETIRED : id)), + modelContextWindows: { [RETIRED]: 1_000_000, "MiniMax-M3": 1_000_000 }, + modelDefaultReasoningEfforts: { [RETIRED]: "high" }, + }); + const projection = projectStartupConfigRepairs(config); + const row = projection.config.providers![provider]!; + expect(projection.changed).toBe(true); + expect(JSON.stringify(projection.config)).not.toContain(RETIRED); + expect(row.models).toEqual(MINIMAX_MODELS); + expect(row.defaultModel).toBe(PREVIEW); + expect(row.modelContextWindows).toEqual({ [PREVIEW]: 1_000_000, "MiniMax-M3": 1_000_000 }); + // The user's saved effort survives the rename rather than being reset to the seed. + expect(row.modelDefaultReasoningEfforts).toEqual({ [PREVIEW]: "high" }); + expect(projectStartupConfigRepairs(structuredClone(projection.config)).changed).toBe(false); + } + }); + test("leaves a hand-edited roster alone", () => { const trimmed = MINIMAX_MODELS_BEFORE_M31.filter(id => id !== "MiniMax-M2"); const reordered = [...MINIMAX_MODELS_BEFORE_M31].reverse(); diff --git a/tests/providers/provider-registry-parity.test.ts b/tests/providers/provider-registry-parity.test.ts index b950f35b714..cf40389c886 100644 --- a/tests/providers/provider-registry-parity.test.ts +++ b/tests/providers/provider-registry-parity.test.ts @@ -201,7 +201,7 @@ describe("provider registry parity", () => { expect(KEY_LOGIN_PROVIDERS).toEqual(deriveKeyLoginMap()); expect(Object.keys(KEY_LOGIN_PROVIDERS)).toEqual(EXPECTED_KEY_PROVIDER_IDS); expect(Object.keys(deriveKeyLoginMap())).toEqual(EXPECTED_KEY_PROVIDER_IDS); - expect(KEY_LOGIN_PROVIDERS.minimax.defaultModel).toBe("MiniMax-M3"); + expect(KEY_LOGIN_PROVIDERS.minimax.defaultModel).toBe("MiniMax-M3.1-Flash"); expect(KEY_LOGIN_PROVIDERS.umans).toMatchObject({ label: "Umans AI Coding Plan", adapter: "anthropic", @@ -507,7 +507,7 @@ describe("provider registry parity", () => { }); const minimaxModels = [ - "MiniMax-M3.1-Flash-Preview", + "MiniMax-M3.1-Flash", "MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M2.5", "MiniMax-M2.5-highspeed", @@ -519,16 +519,16 @@ describe("provider registry parity", () => { const entry = PROVIDER_REGISTRY.find(provider => provider.id === providerId); expect(entry?.adapter).toBe("openai-chat"); expect(entry?.baseUrl).toBe(providerId === "minimax" ? "https://api.minimax.io/v1" : "https://api.minimaxi.com/v1"); - expect(entry?.defaultModel).toBe("MiniMax-M3"); + expect(entry?.defaultModel).toBe("MiniMax-M3.1-Flash"); expect(entry?.models).toEqual(minimaxModels); expect(entry?.modelContextWindows?.["MiniMax-M3"]).toBe(1_000_000); - expect(entry?.modelContextWindows?.["MiniMax-M3.1-Flash-Preview"]).toBe(1_000_000); + expect(entry?.modelContextWindows?.["MiniMax-M3.1-Flash"]).toBe(1_000_000); expect(entry?.modelReasoningEfforts?.["MiniMax-M3"]).toEqual(["low", "medium", "high", "xhigh", "max"]); - expect(entry?.modelReasoningEfforts?.["MiniMax-M3.1-Flash-Preview"]).toEqual(["low", "medium", "high", "xhigh", "max"]); + expect(entry?.modelReasoningEfforts?.["MiniMax-M3.1-Flash"]).toEqual(["low", "medium", "high", "xhigh", "max"]); expect(entry?.modelDefaultReasoningEfforts?.["MiniMax-M3"]).toBe("medium"); - expect(entry?.modelDefaultReasoningEfforts?.["MiniMax-M3.1-Flash-Preview"]).toBe("max"); + expect(entry?.modelDefaultReasoningEfforts?.["MiniMax-M3.1-Flash"]).toBe("max"); expect(entry?.modelReasoningEffortMap?.["MiniMax-M3"]).toMatchObject({ low: "disabled", medium: "adaptive", high: "adaptive" }); - expect(entry?.modelReasoningEffortMap?.["MiniMax-M3.1-Flash-Preview"]).toBeUndefined(); + expect(entry?.modelReasoningEffortMap?.["MiniMax-M3.1-Flash"]).toBeUndefined(); expect(entry?.preserveReasoningContentModels).toEqual(minimaxModels); expect(entry?.reasoningSplitModels).toEqual(splitModels); expect(entry?.reasoningDetailsModels).toEqual(splitModels); From b8ba848cc08671a830163f361455387e55e48fa7 Mon Sep 17 00:00:00 2001 From: Flowershangfromthebranches <152056395+Flowershangfromthebranches@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:32:09 +0800 Subject: [PATCH 089/101] feat(clients): connect Qoder CN and WorkBuddy desktop editions (#7665) * feat(clients): connect Qoder CN and WorkBuddy desktop editions * fix(clients): guard Qoder CN legacy catalog collisions * fix(clients): break the Qoder export import cycle and list new export clients Import ClientPathError from its leaf module in agent-clients.ts and qoder.ts so neither joins the config-export cycle, shrink the import-cycle baseline, and name Qoder CN, WorkBuddy and WorkBuddy AI in the export command summary (CodeRabbit). * test(clients): include Qoder CN and WorkBuddy in the export client roster CI shard 2/4 failed on the exact EXPORT_CLIENT_IDS assertion, which still listed the pre-PR roster (and miscounted it as nineteen). It now names all 23 clients. * test(gui): pin the new client names and WorkBuddy monogram decisions The CI gates job failed on dev's localization and mark guards: Qoder CN, WorkBuddy (CN) and WorkBuddy AI are product names kept in English (fr and zh-TW allowlists, like Qoder), and both WorkBuddy editions deliberately render the monogram until a mark is wired for each. --------- Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> --- .../src/content/docs/guides/integrations.md | 39 +++++++- .../content/docs/ja/guides/integrations.md | 2 +- .../content/docs/ko/guides/integrations.md | 2 +- .../content/docs/ru/guides/integrations.md | 4 +- .../content/docs/zh-cn/guides/integrations.md | 2 +- gui/src/app-routing.ts | 3 + .../client-config-clients.ts | 6 +- gui/src/components/integration-marks.ts | 3 + gui/src/i18n/de.ts | 6 ++ gui/src/i18n/en.ts | 6 ++ gui/src/i18n/fr.ts | 6 ++ gui/src/i18n/ja.ts | 6 ++ gui/src/i18n/ko.ts | 6 ++ gui/src/i18n/pt.ts | 6 ++ gui/src/i18n/ru.ts | 6 ++ gui/src/i18n/tr.ts | 6 ++ gui/src/i18n/vi.ts | 6 ++ gui/src/i18n/zh-TW.ts | 6 ++ gui/src/i18n/zh.ts | 6 ++ .../integrations/FileIntegrationPage.tsx | 6 ++ gui/src/pages/integrations/integration-api.ts | 4 + .../pages/integrations/integration-tabs.ts | 6 ++ .../pages/integrations/overview-clients.ts | 3 + gui/tests/client-config-panel.test.tsx | 7 +- gui/tests/client-marks-assets.test.ts | 5 +- gui/tests/fr-localization.test.ts | 4 + gui/tests/integration-marks.test.ts | 5 +- gui/tests/integrations-api.test.ts | 2 +- gui/tests/locale-parity.test.ts | 4 + scripts/test-layout/layout-clients.json | 2 + src/cli/dispatch.ts | 2 +- src/cli/export-command.ts | 2 +- src/cli/help.ts | 2 +- src/cli/integration-plan-dto.ts | 4 + src/cli/registry.ts | 4 +- src/clients/config-export.ts | 12 +++ src/clients/config-export/agent-clients.ts | 96 ++++++++++++++++++ src/clients/config-export/contracts.ts | 5 + src/clients/config-export/qoder.ts | 2 +- src/integrations/catalog-refresh.ts | 2 +- src/integrations/mutation-plan.ts | 7 ++ src/integrations/registry.ts | 5 + src/integrations/state.ts | 2 +- src/server/management/config-routes.ts | 4 +- src/server/management/model-routes.ts | 4 +- structure/clients/integrations.md | 3 +- structure/clients/qoder.md | 8 +- tests/cli/cli-dispatch.test.ts | 2 +- tests/clients/agent-clients.test.ts | 97 +++++++++++++++++++ tests/clients/integrations-state.test.ts | 2 +- tests/clients/qoder-client.test.ts | 70 ++++++------- ...ync-client-integrations-unattended.test.ts | 2 +- .../clients/sync-client-integrations.test.ts | 4 +- .../workbuddy-selector-recovery.test.ts | 76 +++++++++++++++ .../client-config-export-new-clients.test.ts | 4 +- tests/config/client-config-export.test.ts | 4 +- tests/fixtures/import-cycle-baseline.json | 2 - tests/fixtures/test-layout-expected.json | 2 +- tests/gui/integrations-invariants.test.ts | 7 +- 59 files changed, 535 insertions(+), 76 deletions(-) create mode 100644 src/clients/config-export/agent-clients.ts create mode 100644 tests/clients/agent-clients.test.ts create mode 100644 tests/clients/workbuddy-selector-recovery.test.ts diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index abd25f4332d..fdb813c12b6 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -4,7 +4,7 @@ description: Connect opencodex to OpenCode, Pi, Gentle Shell, OMP, Hermes, OpenC --- The **Connect** page writes opencodex's provider block into a client's own config -file, and removes it again. Twenty clients work this way, each with a switch: +file, and removes it again. Each client below has its own switch: | Client | Config file | Format | When the change takes effect | Credential | |---|---|---|---|---| @@ -24,6 +24,9 @@ file, and removes it again. Twenty clients work this way, each with a switch: | Raycast | `~/.config/raycast/ai/providers.yaml` | YAML | immediately on save — Raycast watches the file | none — loopback only | | omo (Pi / senpi) | `~/.omo/agent/models.json` | JSON | new sessions | loopback placeholder | | Qoder | `settings.json` in `$QODER_CONFIG_DIR`, default `~/.qoder` | JSON | new sessions | loopback placeholder | +| Qoder CN | `settings.json` in `$QODERCN_CONFIG_DIR`, default `~/.qoder-cn` | JSON | new sessions | loopback placeholder | +| WorkBuddy (CN) | `~/.workbuddy/models.json` | JSON | auto reload / new sessions | loopback placeholder | +| WorkBuddy AI | `~/.workbuddy-ai/models.json` | JSON | auto reload / new sessions | loopback placeholder | | Cline CLI | `~/.cline/data/settings/providers.json` and sibling `models.json` | JSON pair | after stopping and restarting Cline | loopback placeholder | | Kilo | first existing `kilo.jsonc`, `kilo.json`, `opencode.jsonc`, `opencode.json`, or `config.json` under `~/.config/kilo` | JSONC | new sessions | `OPENCODEX_KILO_API_KEY` | | Command Code | `~/.commandcode/providers.json` (on Windows with the default home: use `HOME ?? USERPROFILE` only if nonblank and Windows-absolute, otherwise `homedir()`; a separately supplied home is preserved; append `.commandcode\providers.json`) | JSON | next launch of Command Code | none — keyless loopback (`apiKey: false`) | @@ -65,6 +68,40 @@ This integration retains Pi’s loopback-only export policy and stores no real A [omo (Codex / LazyCodex) role models](#omo-codex--lazycodex-role-models). OpenCode-based omo (oh-my-opencode) keeps its own config under OpenCode; opencodex does not read or write it. +### Qoder CN and WorkBuddy editions + +In **Connect**, enable **Qoder CN**, **WorkBuddy (CN)** or **WorkBuddy AI**, then choose +an OpenCodex model in the client's custom-model picker. The proxy must stay running. +The first enable saves a snapshot; disable removes only the generated contribution, +and Undo restores the snapshot. Existing models and unrelated settings remain intact. +These are separate connections from Global Qoder and from each other. + +The same operations are available through the CLI (replace `workbuddy` with +`workbuddy-ai` or `qoder-cn` for the other editions): + +```bash +ocx integration client enable --client workbuddy +ocx export --client workbuddy --json +ocx integration client disable --client workbuddy +``` + +Qoder CN honors `QODERCN_CONFIG_DIR`. Both WorkBuddy editions honor their native +`WORKBUDDY_CONFIG_DIR`, then `CODEBUDDY_CONFIG_DIR`; unset overrides keep the regional +roots in the table. Overrides must be absolute, `~`, or start with `~/` or `~\`. WorkBuddy's legacy +array configuration and its object-form `models` list are both supported. If an existing +`availableModels` restriction excludes OpenCodex models, remove that restriction in +WorkBuddy before enabling. Project-level settings can override user-level models. + +Connections require an unauthenticated loopback listener; no upstream credentials are +copied into these clients. WorkBuddy receives the full Chat Completions URL and the +capabilities declared by the OpenCodex catalog. Restart the client or start a new session +if its model picker has not reloaded yet. Configuration/lifecycle tests do not establish +end-to-end compatibility with every upstream model or every client release. + +Doubao Desktop and Devin currently have no verified arbitrary-provider configuration +contract in this integration. They have no enable switch; adding a local JSON file alone +would not make their model pickers consume it. + Qoder IDE / qodercli supports export and managed configuration through the Dashboard and CLI: ```bash diff --git a/docs-site/src/content/docs/ja/guides/integrations.md b/docs-site/src/content/docs/ja/guides/integrations.md index 1cecbcb394f..3a952318d4e 100644 --- a/docs-site/src/content/docs/ja/guides/integrations.md +++ b/docs-site/src/content/docs/ja/guides/integrations.md @@ -3,7 +3,7 @@ title: クライアント統合 description: ダッシュボードから opencodex を OpenCode、Pi、OMP、Hermes、OpenClaw、Kimi Code、gjc、DeepSeek Harness、MiniMax Code、ZCode、Prime Agent、Aside、Raycast、omo、Cline CLI、Kilo、Command Code、Factory Droid に接続します。クライアントごとにスイッチがあり、書き込み前には必ずバックアップを取ります。 --- -**接続** タブは、各クライアントの設定ファイルに opencodex のプロバイダーブロックを書き込み、必要に応じて削除します。次の 18 クライアントは、それぞれのスイッチで管理できます。 +**接続** タブは、各クライアントの設定ファイルに opencodex のプロバイダーブロックを書き込み、必要に応じて削除します。以下のクライアントは、それぞれのスイッチで管理できます。 | クライアント | 設定ファイル | 形式 | 変更が反映される時点 | 認証情報 | |---|---|---|---|---| diff --git a/docs-site/src/content/docs/ko/guides/integrations.md b/docs-site/src/content/docs/ko/guides/integrations.md index c5a058d89be..aa51c5a3bf6 100644 --- a/docs-site/src/content/docs/ko/guides/integrations.md +++ b/docs-site/src/content/docs/ko/guides/integrations.md @@ -3,7 +3,7 @@ title: 연동 description: 대시보드에서 OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside, Raycast, omo, Cline CLI, Kilo, Command Code와 Factory Droid를 opencodex에 연결합니다. 클라이언트마다 스위치가 하나씩 있으며 기록 전마다 백업합니다. --- -**연결** 페이지는 클라이언트의 설정 파일에 opencodex 프로바이더 블록을 쓰고 다시 제거합니다. 다음 18개 클라이언트는 각각 스위치로 관리합니다. +**연결** 페이지는 클라이언트의 설정 파일에 opencodex 프로바이더 블록을 쓰고 다시 제거합니다. 다음 클라이언트는 각각 스위치로 관리합니다. | 클라이언트 | 설정 파일 | 형식 | 변경 적용 시점 | 자격 증명 | |---|---|---|---|---| diff --git a/docs-site/src/content/docs/ru/guides/integrations.md b/docs-site/src/content/docs/ru/guides/integrations.md index b5e4929dcb1..0aebfa48c8a 100644 --- a/docs-site/src/content/docs/ru/guides/integrations.md +++ b/docs-site/src/content/docs/ru/guides/integrations.md @@ -4,8 +4,8 @@ description: Подключайте opencodex к OpenCode, Pi, OMP, Hermes, Open --- Вкладка **Подключение** записывает блок провайдера opencodex в собственный файл -конфигурации клиента и при необходимости удаляет его. Так работают восемнадцать -клиентов, у каждого свой переключатель: +конфигурации клиента и при необходимости удаляет его. Так работают следующие +клиенты, у каждого свой переключатель: | Клиент | Файл конфигурации | Формат | Когда изменение начинает действовать | Учётные данные | |---|---|---|---|---| diff --git a/docs-site/src/content/docs/zh-cn/guides/integrations.md b/docs-site/src/content/docs/zh-cn/guides/integrations.md index 4dab03cdc9b..65ff2c1a994 100644 --- a/docs-site/src/content/docs/zh-cn/guides/integrations.md +++ b/docs-site/src/content/docs/zh-cn/guides/integrations.md @@ -3,7 +3,7 @@ title: 集成 description: 从仪表盘将 opencodex 连接到 OpenCode、Pi、OMP、Hermes、OpenClaw、Kimi Code、gjc、DeepSeek Harness、MiniMax Code、ZCode、Prime Agent、Aside、Raycast、omo、Cline CLI、Kilo、Command Code 和 Factory Droid;每个客户端都有独立开关,且每次写入前都会备份。 --- -**连接** 标签页可将 opencodex 的提供商配置块写入客户端自己的配置文件,也可再次移除。以下 18 个客户端都采用这种方式,各有独立开关: +**连接** 标签页可将 opencodex 的提供商配置块写入客户端自己的配置文件,也可再次移除。以下客户端都采用这种方式,各有独立开关: | 客户端 | 配置文件 | 格式 | 变更生效时间 | 凭据 | |---|---|---|---|---| diff --git a/gui/src/app-routing.ts b/gui/src/app-routing.ts index 51ec534ee3c..ce6a22fab16 100644 --- a/gui/src/app-routing.ts +++ b/gui/src/app-routing.ts @@ -149,6 +149,9 @@ export const INTEGRATION_TAB_HASHES = [ "integrations/kilo", "integrations/droid", "integrations/qoder", + "integrations/qoder-cn", + "integrations/workbuddy", + "integrations/workbuddy-ai", ] as const; /** diff --git a/gui/src/components/apikeys-workspace/client-config-clients.ts b/gui/src/components/apikeys-workspace/client-config-clients.ts index f33555f7b23..64703e980a8 100644 --- a/gui/src/components/apikeys-workspace/client-config-clients.ts +++ b/gui/src/components/apikeys-workspace/client-config-clients.ts @@ -8,11 +8,14 @@ * with EXPORT_CLIENT_IDS by hand; adding a client server-side renders no row * until this tuple changes. */ -export const CLIENTS = ["qoder", "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "kilo", "droid", "gentle-shell"] as const; +export const CLIENTS = ["qoder", "qoder-cn", "workbuddy", "workbuddy-ai", "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "kilo", "droid", "gentle-shell"] as const; export type ExportClientId = (typeof CLIENTS)[number]; export const CLIENT_LABEL_KEYS = { qoder: "api.clientConfig.clientQoder", + "qoder-cn": "integrations.tab.qoderCn", + "workbuddy": "integrations.tab.workbuddy", + "workbuddy-ai": "integrations.tab.workbuddyAi", opencode: "api.clientConfig.clientOpencode", pi: "api.clientConfig.clientPi", "gentle-shell": "integrations.tab.gentleShell", @@ -67,6 +70,7 @@ export const CLIENT_LABEL_KEYS = { */ export const CLIENT_MARKS: Partial> = { qoder: "/provider-icons/qoder.svg", + "qoder-cn": "/provider-icons/qoder.svg", opencode: "/provider-icons/opencode.svg", pi: "/provider-icons/pi.svg", omp: "/provider-icons/oh-my-pi.svg", diff --git a/gui/src/components/integration-marks.ts b/gui/src/components/integration-marks.ts index 73af7a8ddd5..b641e8c0b5e 100644 --- a/gui/src/components/integration-marks.ts +++ b/gui/src/components/integration-marks.ts @@ -66,6 +66,9 @@ export const INTEGRATION_MARKS: Record = { kilo: CLIENT_MARKS.kilo ?? null, droid: CLIENT_MARKS.droid ?? null, qoder: CLIENT_MARKS.qoder ?? null, + "qoder-cn": CLIENT_MARKS["qoder-cn"] ?? null, + workbuddy: null, + "workbuddy-ai": null, }; /** diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 6e9bf71f05d..3edd48748e0 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1646,6 +1646,12 @@ export const de: Record = { "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", "integrations.tab.omo": "omo", + "integrations.tab.qoderCn": "Qoder CN", + "integrations.semantics.qoderCn": "Verwaltet OpenCodex-Modelle in ~/.qoder-cn/settings.json. Erfordert Loopback-Zugriff. Andere Einstellungen bleiben erhalten; Rückgängig stellt den Snapshot wieder her.", + "integrations.tab.workbuddy": "WorkBuddy (CN)", + "integrations.semantics.workbuddy": "Verwaltet OpenCodex-Modelle in ~/.workbuddy/models.json. Erfordert Loopback-Zugriff. Andere Einstellungen bleiben erhalten; Rückgängig stellt den Snapshot wieder her.", + "integrations.tab.workbuddyAi": "WorkBuddy AI", + "integrations.semantics.workbuddyAi": "Verwaltet OpenCodex-Modelle in ~/.workbuddy-ai/models.json. Erfordert Loopback-Zugriff. Andere Einstellungen bleiben erhalten; Rückgängig stellt den Snapshot wieder her.", "integrations.tab.qoder": "Qoder", "api.clientConfig.clientQoder": "Qoder", "integrations.semantics.qoder": "Verwaltet OpenCodex-Anbieter und eigene Modelle in ~/.qoder/settings.json. Erfordert Loopback-Zugriff. Andere Einstellungen bleiben erhalten; Rückgängig stellt den Snapshot wieder her.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index e5aa8db2d1f..538167a0c02 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2232,6 +2232,12 @@ export const en = { "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", "integrations.tab.omo": "omo", + "integrations.tab.qoderCn": "Qoder CN", + "integrations.semantics.qoderCn": "Manages OpenCodex models in ~/.qoder-cn/settings.json. Requires loopback access. Other settings are preserved; Undo restores the snapshot.", + "integrations.tab.workbuddy": "WorkBuddy (CN)", + "integrations.semantics.workbuddy": "Manages OpenCodex models in ~/.workbuddy/models.json. Requires loopback access. Other settings are preserved; Undo restores the snapshot.", + "integrations.tab.workbuddyAi": "WorkBuddy AI", + "integrations.semantics.workbuddyAi": "Manages OpenCodex models in ~/.workbuddy-ai/models.json. Requires loopback access. Other settings are preserved; Undo restores the snapshot.", "integrations.tab.qoder": "Qoder", "api.clientConfig.clientQoder": "Qoder", "integrations.semantics.qoder": "Manages OpenCodex providers and custom models in ~/.qoder/settings.json. Requires loopback access. Other settings are preserved; Undo restores the snapshot.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 06c65d62cd2..772fdf9cca0 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2204,6 +2204,12 @@ export const fr: Record = { "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", "integrations.tab.omo": "omo", + "integrations.tab.qoderCn": "Qoder CN", + "integrations.semantics.qoderCn": "Gère les modèles OpenCodex dans ~/.qoder-cn/settings.json. Accès local requis. Les autres réglages sont conservés ; Annuler restaure la sauvegarde.", + "integrations.tab.workbuddy": "WorkBuddy (CN)", + "integrations.semantics.workbuddy": "Gère les modèles OpenCodex dans ~/.workbuddy/models.json. Accès local requis. Les autres réglages sont conservés ; Annuler restaure la sauvegarde.", + "integrations.tab.workbuddyAi": "WorkBuddy AI", + "integrations.semantics.workbuddyAi": "Gère les modèles OpenCodex dans ~/.workbuddy-ai/models.json. Accès local requis. Les autres réglages sont conservés ; Annuler restaure la sauvegarde.", "integrations.tab.qoder": "Qoder", "api.clientConfig.clientQoder": "Qoder", "integrations.semantics.qoder": "Gère les fournisseurs OpenCodex et les modèles personnalisés dans ~/.qoder/settings.json. Nécessite qu’opencodex soit joignable sur une adresse de bouclage. Les autres paramètres sont conservés ; Annuler restaure la sauvegarde.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 751c3053bfa..067dc34a507 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2099,6 +2099,12 @@ export const ja: Record = { "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", "integrations.tab.omo": "omo", + "integrations.tab.qoderCn": "Qoder CN", + "integrations.semantics.qoderCn": "~/.qoder-cn/settings.json の OpenCodex モデルを管理します。ループバック接続が必要です。他の設定を保持し、元に戻すとスナップショットを復元します。", + "integrations.tab.workbuddy": "WorkBuddy (CN)", + "integrations.semantics.workbuddy": "~/.workbuddy/models.json の OpenCodex モデルを管理します。ループバック接続が必要です。他の設定を保持し、元に戻すとスナップショットを復元します。", + "integrations.tab.workbuddyAi": "WorkBuddy AI", + "integrations.semantics.workbuddyAi": "~/.workbuddy-ai/models.json の OpenCodex モデルを管理します。ループバック接続が必要です。他の設定を保持し、元に戻すとスナップショットを復元します。", "integrations.tab.qoder": "Qoder", "api.clientConfig.clientQoder": "Qoder", "integrations.semantics.qoder": "~/.qoder/settings.json の OpenCodex プロバイダーとカスタムモデルを管理します。ループバック接続が必要です。他の設定は保持され、元に戻すとスナップショットが復元されます。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 4e5f35e6e32..8237f6c18c6 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1690,6 +1690,12 @@ export const ko: Record = { "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", "integrations.tab.omo": "omo", + "integrations.tab.qoderCn": "Qoder CN", + "integrations.semantics.qoderCn": "~/.qoder-cn/settings.json에서 OpenCodex 모델을 관리합니다. 루프백 연결이 필요합니다. 다른 설정은 유지되며 실행 취소로 스냅샷을 복원합니다.", + "integrations.tab.workbuddy": "WorkBuddy (CN)", + "integrations.semantics.workbuddy": "~/.workbuddy/models.json에서 OpenCodex 모델을 관리합니다. 루프백 연결이 필요합니다. 다른 설정은 유지되며 실행 취소로 스냅샷을 복원합니다.", + "integrations.tab.workbuddyAi": "WorkBuddy AI", + "integrations.semantics.workbuddyAi": "~/.workbuddy-ai/models.json에서 OpenCodex 모델을 관리합니다. 루프백 연결이 필요합니다. 다른 설정은 유지되며 실행 취소로 스냅샷을 복원합니다.", "integrations.tab.qoder": "Qoder", "api.clientConfig.clientQoder": "Qoder", "integrations.semantics.qoder": "~/.qoder/settings.json의 OpenCodex 공급자와 사용자 지정 모델을 관리합니다. 루프백 접근이 필요합니다. 다른 설정은 유지되며 실행 취소 시 스냅샷을 복원합니다.", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index 86515085682..6a699319003 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -2218,6 +2218,12 @@ export const pt: Record = { "integrations.tab.cline": "Cline CLI", "integrations.tab.commandcode": "Command Code", "integrations.tab.kilo": "Kilo", + "integrations.tab.qoderCn": "Qoder CN", + "integrations.semantics.qoderCn": "Gerencia modelos OpenCodex em ~/.qoder-cn/settings.json. Requer acesso local. Outras configurações são preservadas; Desfazer restaura o snapshot.", + "integrations.tab.workbuddy": "WorkBuddy (CN)", + "integrations.semantics.workbuddy": "Gerencia modelos OpenCodex em ~/.workbuddy/models.json. Requer acesso local. Outras configurações são preservadas; Desfazer restaura o snapshot.", + "integrations.tab.workbuddyAi": "WorkBuddy AI", + "integrations.semantics.workbuddyAi": "Gerencia modelos OpenCodex em ~/.workbuddy-ai/models.json. Requer acesso local. Outras configurações são preservadas; Desfazer restaura o snapshot.", "integrations.tab.qoder": "Qoder", "integrations.tab.droid": "Factory Droid", "integrations.aside.profilesTitle": "Perfis do Aside", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index b19c9bb91bc..028a9f775ed 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2190,6 +2190,12 @@ export const ru: Record = { "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", "integrations.tab.omo": "omo", + "integrations.tab.qoderCn": "Qoder CN", + "integrations.semantics.qoderCn": "Управляет моделями OpenCodex в ~/.qoder-cn/settings.json. Требуется loopback. Остальные настройки сохраняются; отмена восстанавливает снимок.", + "integrations.tab.workbuddy": "WorkBuddy (CN)", + "integrations.semantics.workbuddy": "Управляет моделями OpenCodex в ~/.workbuddy/models.json. Требуется loopback. Остальные настройки сохраняются; отмена восстанавливает снимок.", + "integrations.tab.workbuddyAi": "WorkBuddy AI", + "integrations.semantics.workbuddyAi": "Управляет моделями OpenCodex в ~/.workbuddy-ai/models.json. Требуется loopback. Остальные настройки сохраняются; отмена восстанавливает снимок.", "integrations.tab.qoder": "Qoder", "api.clientConfig.clientQoder": "Qoder", "integrations.semantics.qoder": "Управляет провайдерами OpenCodex и пользовательскими моделями в ~/.qoder/settings.json. Требуется доступ через loopback. Остальные настройки сохраняются; отмена восстанавливает снимок.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 045137082a9..9965c216db9 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2217,6 +2217,12 @@ export const tr: Record = { "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", "integrations.tab.omo": "omo", + "integrations.tab.qoderCn": "Qoder CN", + "integrations.semantics.qoderCn": "~/.qoder-cn/settings.json içindeki OpenCodex modellerini yönetir. Yerel bağlantı gerekir. Diğer ayarlar korunur; Geri Al anlık görüntüyü geri yükler.", + "integrations.tab.workbuddy": "WorkBuddy (CN)", + "integrations.semantics.workbuddy": "~/.workbuddy/models.json içindeki OpenCodex modellerini yönetir. Yerel bağlantı gerekir. Diğer ayarlar korunur; Geri Al anlık görüntüyü geri yükler.", + "integrations.tab.workbuddyAi": "WorkBuddy AI", + "integrations.semantics.workbuddyAi": "~/.workbuddy-ai/models.json içindeki OpenCodex modellerini yönetir. Yerel bağlantı gerekir. Diğer ayarlar korunur; Geri Al anlık görüntüyü geri yükler.", "integrations.tab.qoder": "Qoder", "api.clientConfig.clientQoder": "Qoder", "integrations.semantics.qoder": "~/.qoder/settings.json içindeki OpenCodex sağlayıcılarını ve özel modelleri yönetir. Loopback erişimi gerektirir. Diğer ayarlar korunur; Geri al, anlık görüntüyü geri yükler.", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index b53ab8972d2..c416a426462 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -2180,6 +2180,12 @@ export const vi: Record = { "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", "integrations.tab.omo": "omo", + "integrations.tab.qoderCn": "Qoder CN", + "integrations.semantics.qoderCn": "Quản lý mô hình OpenCodex trong ~/.qoder-cn/settings.json. Cần kết nối loopback. Giữ nguyên cài đặt khác; Hoàn tác khôi phục bản sao lưu.", + "integrations.tab.workbuddy": "WorkBuddy (CN)", + "integrations.semantics.workbuddy": "Quản lý mô hình OpenCodex trong ~/.workbuddy/models.json. Cần kết nối loopback. Giữ nguyên cài đặt khác; Hoàn tác khôi phục bản sao lưu.", + "integrations.tab.workbuddyAi": "WorkBuddy AI", + "integrations.semantics.workbuddyAi": "Quản lý mô hình OpenCodex trong ~/.workbuddy-ai/models.json. Cần kết nối loopback. Giữ nguyên cài đặt khác; Hoàn tác khôi phục bản sao lưu.", "integrations.tab.qoder": "Qoder", "api.clientConfig.clientQoder": "Qoder", "integrations.semantics.qoder": "Quản lý nhà cung cấp OpenCodex và mô hình tùy chỉnh trong ~/.qoder/settings.json. Yêu cầu truy cập loopback. Giữ nguyên các cài đặt khác; Hoàn tác khôi phục bản chụp.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index e7d73063988..a2d1b5a9a7c 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -3137,6 +3137,12 @@ export const zhTW: Record = { "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", "integrations.tab.omo": "omo", + "integrations.tab.qoderCn": "Qoder CN", + "integrations.semantics.qoderCn": "在 ~/.qoder-cn/settings.json 中管理 OpenCodex 模型。需要本機回環連線。保留其他設定;復原可還原快照。", + "integrations.tab.workbuddy": "WorkBuddy (CN)", + "integrations.semantics.workbuddy": "在 ~/.workbuddy/models.json 中管理 OpenCodex 模型。需要本機回環連線。保留其他設定;復原可還原快照。", + "integrations.tab.workbuddyAi": "WorkBuddy AI", + "integrations.semantics.workbuddyAi": "在 ~/.workbuddy-ai/models.json 中管理 OpenCodex 模型。需要本機回環連線。保留其他設定;復原可還原快照。", "integrations.tab.qoder": "Qoder", "api.clientConfig.clientQoder": "Qoder", "integrations.semantics.qoder": "管理 ~/.qoder/settings.json 中的 OpenCodex 供應商和自訂模型。需要回環存取。保留其他設定;復原會還原快照。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index b44f55410ef..23075e443f1 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1663,6 +1663,12 @@ export const zh: Record = { "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", "integrations.tab.omo": "omo", + "integrations.tab.qoderCn": "Qoder CN", + "integrations.semantics.qoderCn": "在 ~/.qoder-cn/settings.json 中管理 OpenCodex 模型。需要本机回环连接。保留其他设置;撤销可恢复快照。", + "integrations.tab.workbuddy": "WorkBuddy (CN)", + "integrations.semantics.workbuddy": "在 ~/.workbuddy/models.json 中管理 OpenCodex 模型。需要本机回环连接。保留其他设置;撤销可恢复快照。", + "integrations.tab.workbuddyAi": "WorkBuddy AI", + "integrations.semantics.workbuddyAi": "在 ~/.workbuddy-ai/models.json 中管理 OpenCodex 模型。需要本机回环连接。保留其他设置;撤销可恢复快照。", "integrations.tab.qoder": "Qoder", "api.clientConfig.clientQoder": "Qoder", "integrations.semantics.qoder": "管理 ~/.qoder/settings.json 中的 OpenCodex 提供商和自定义模型。需要回环访问。保留其他设置;撤销会恢复快照。", diff --git a/gui/src/pages/integrations/FileIntegrationPage.tsx b/gui/src/pages/integrations/FileIntegrationPage.tsx index 67b06ebb8c1..fec85ff669c 100644 --- a/gui/src/pages/integrations/FileIntegrationPage.tsx +++ b/gui/src/pages/integrations/FileIntegrationPage.tsx @@ -90,6 +90,9 @@ const SEMANTICS_KEY: Record = { kilo: "integrations.semantics.kilo", droid: "integrations.semantics.droid", qoder: "integrations.semantics.qoder", + "qoder-cn": "integrations.semantics.qoderCn", + "workbuddy": "integrations.semantics.workbuddy", + "workbuddy-ai": "integrations.semantics.workbuddyAi", }; const TAB_LABEL_KEY: Record = { @@ -113,6 +116,9 @@ const TAB_LABEL_KEY: Record = { kilo: "integrations.tab.kilo", droid: "integrations.tab.droid", qoder: "integrations.tab.qoder", + "qoder-cn": "integrations.tab.qoderCn", + "workbuddy": "integrations.tab.workbuddy", + "workbuddy-ai": "integrations.tab.workbuddyAi", }; type FileIntegrationPageProps = { diff --git a/gui/src/pages/integrations/integration-api.ts b/gui/src/pages/integrations/integration-api.ts index 0145651e9c6..2f01d0a9356 100644 --- a/gui/src/pages/integrations/integration-api.ts +++ b/gui/src/pages/integrations/integration-api.ts @@ -3,6 +3,9 @@ import { parseAsideProfileStatus, parseAsideProfileOutcomes, type AsideProfileOu export const FILE_INTEGRATION_CLIENTS = [ "qoder", + "qoder-cn", + "workbuddy", + "workbuddy-ai", "opencode", "pi", "omp", @@ -281,6 +284,7 @@ const MISSING_STORE_DOCUMENT = /^[\x20-\x7e]{1,64}$/; const PLAN_CHANGE_KEYS = new Set(["kind", "path"]); const PLAN_PSEUDO_PATHS = new Set(["$snapshot", "$ownership", "$journal"]); const PLAN_SCHEMA_PATHS = new Set([ + "*", // A WorkBuddy legacy document is a top-level model array. "provider.opencodex", "providers.opencodex", "models.providers.opencodex", diff --git a/gui/src/pages/integrations/integration-tabs.ts b/gui/src/pages/integrations/integration-tabs.ts index 2ce517ebd84..6a934215b83 100644 --- a/gui/src/pages/integrations/integration-tabs.ts +++ b/gui/src/pages/integrations/integration-tabs.ts @@ -55,6 +55,9 @@ export const TABS: readonly TabDefinition[] = [ { id: "raycast", hash: "integrations/raycast", labelKey: "integrations.tab.raycast" }, { id: "omo", hash: "integrations/omo", labelKey: "integrations.tab.omo" }, { id: "qoder", hash: "integrations/qoder", labelKey: "integrations.tab.qoder" }, + { id: "qoder-cn", hash: "integrations/qoder-cn", labelKey: "integrations.tab.qoderCn" }, + { id: "workbuddy", hash: "integrations/workbuddy", labelKey: "integrations.tab.workbuddy" }, + { id: "workbuddy-ai", hash: "integrations/workbuddy-ai", labelKey: "integrations.tab.workbuddyAi" }, { id: "cline", hash: "integrations/cline", labelKey: "integrations.tab.cline" }, { id: "kilo", hash: "integrations/kilo", labelKey: "integrations.tab.kilo" }, { id: "droid", hash: "integrations/droid", labelKey: "integrations.tab.droid" }, @@ -81,4 +84,7 @@ export const FILE_CLIENTS = new Set([ "kilo", "droid", "qoder", + "qoder-cn", + "workbuddy", + "workbuddy-ai", ]); diff --git a/gui/src/pages/integrations/overview-clients.ts b/gui/src/pages/integrations/overview-clients.ts index 9454a4ca046..8638624fa8e 100644 --- a/gui/src/pages/integrations/overview-clients.ts +++ b/gui/src/pages/integrations/overview-clients.ts @@ -161,6 +161,9 @@ const FILE_LABEL_KEY: Record = { kilo: "integrations.tab.kilo", droid: "integrations.tab.droid", qoder: "integrations.tab.qoder", + "qoder-cn": "integrations.tab.qoderCn", + "workbuddy": "integrations.tab.workbuddy", + "workbuddy-ai": "integrations.tab.workbuddyAi", }; /** A file client's block is in the file for both `current` and `stale`. */ diff --git a/gui/tests/client-config-panel.test.tsx b/gui/tests/client-config-panel.test.tsx index 09a09afbfbf..dc96e5265b3 100644 --- a/gui/tests/client-config-panel.test.tsx +++ b/gui/tests/client-config-panel.test.tsx @@ -171,7 +171,7 @@ function rowButton(container: HTMLElement, name: string, label: string): HTMLBut } test("the API download surface includes DSH, MiniMax Code, Aside, Raycast and omo as clients", () => { - expect(CLIENTS).toEqual(["qoder", "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "kilo", "droid", "gentle-shell"]); + expect(CLIENTS).toEqual(["qoder", "qoder-cn", "workbuddy", "workbuddy-ai", "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "kilo", "droid", "gentle-shell"]); expect(CLIENT_LABEL_KEYS["gentle-shell"]).toBe("integrations.tab.gentleShell"); expect(CLIENT_LABEL_KEYS.dsh).toBe("api.clientConfig.clientDsh"); expect(CLIENT_LABEL_KEYS.mcode).toBe("api.clientConfig.clientMcode"); @@ -252,7 +252,7 @@ test("clients render as rows, not a switch", async () => { expect(container.querySelector(".awi-clientconfig-segmented")).toBeNull(); const names = [...container.querySelectorAll(".awi-clientconfig-name")].map(el => el.textContent); // Row order follows the registry, so a new client appears without a code change here. - expect(names?.slice(0, 2)).toEqual(["Qoder", "OpenCode"]); + expect(names?.slice(0, 5)).toEqual(["Qoder", "Qoder CN", "WorkBuddy (CN)", "WorkBuddy AI", "OpenCode"]); expect(names).toHaveLength(CLIENTS.length); // Each row states where its file goes; a Download with no destination is the // ambiguity the announcement text works to prevent. @@ -331,7 +331,8 @@ test("each client row shows its own brand mark, never a borrowed one", async () const sources = [...imgSources, ...maskSources] .filter((src): src is string => src !== null && src !== ""); expect(sources.length).toBeGreaterThan(1); - expect(new Set(sources).size).toBe(sources.length); + // Global and CN editions share Qoder's own brand asset. + expect(new Set(sources).size).toBe(sources.length - 1); // Marks are decoration: the row already names its client in text. for (const img of container.querySelectorAll("img")) { expect(img.getAttribute("alt")).toBe(""); diff --git a/gui/tests/client-marks-assets.test.ts b/gui/tests/client-marks-assets.test.ts index d3d0e052c0e..82264db8bf2 100644 --- a/gui/tests/client-marks-assets.test.ts +++ b/gui/tests/client-marks-assets.test.ts @@ -115,9 +115,10 @@ test("every mark's provenance is recorded in the README", () => { * key was renamed or an entry dropped in a merge, looks identical to a client * that never had one. This is the difference. */ -test("only Gentle Shell uses the deliberate monogram fallback", () => { +// The two WorkBuddy editions also take the monogram until a mark is wired for each (#7665). +test("only Gentle Shell and the WorkBuddy editions use the deliberate monogram fallback", () => { const monogram = CLIENTS.filter(clientId => CLIENT_MARKS[clientId] === undefined); - expect(monogram).toEqual(["gentle-shell"]); + expect(monogram).toEqual(["workbuddy", "workbuddy-ai", "gentle-shell"]); }); /* diff --git a/gui/tests/fr-localization.test.ts b/gui/tests/fr-localization.test.ts index cf616580389..a0ffa94a0ca 100644 --- a/gui/tests/fr-localization.test.ts +++ b/gui/tests/fr-localization.test.ts @@ -168,6 +168,10 @@ const INTENTIONAL_ENGLISH = new Set([ // Qoder is a product name, identical in every locale. "integrations.tab.qoder", "api.clientConfig.clientQoder", + // Qoder CN and the two WorkBuddy editions are product names too. + "integrations.tab.qoderCn", + "integrations.tab.workbuddy", + "integrations.tab.workbuddyAi", "models.reasoningEffort.minimal", "models.reasoningEffort.max", "models.reasoningEffort.ultra", diff --git a/gui/tests/integration-marks.test.ts b/gui/tests/integration-marks.test.ts index daed6d91142..c09b89113bd 100644 --- a/gui/tests/integration-marks.test.ts +++ b/gui/tests/integration-marks.test.ts @@ -34,11 +34,12 @@ test("every Integrations row has a mark that resolves to a committed file", () = * client was added without a mark decision, and both look like a rendering bug * from the outside. */ -test("only Gentle Shell deliberately falls back to its own monogram", () => { +// The two WorkBuddy editions also take the monogram until a mark is wired for each (#7665). +test("only Gentle Shell and the WorkBuddy editions deliberately fall back to a monogram", () => { const monogram = Object.entries(INTEGRATION_MARKS) .filter(([, src]) => src === null) .map(([id]) => id); - expect(monogram).toEqual(["gentle-shell"]); + expect(monogram).toEqual(["gentle-shell", "workbuddy", "workbuddy-ai"]); }); /* diff --git a/gui/tests/integrations-api.test.ts b/gui/tests/integrations-api.test.ts index 79041fa25c9..9640fff43fc 100644 --- a/gui/tests/integrations-api.test.ts +++ b/gui/tests/integrations-api.test.ts @@ -21,7 +21,7 @@ const originalFetch = globalThis.fetch; test("all registered export clients include Cline in file integrations", () => { expect(FILE_INTEGRATION_CLIENTS).toEqual([ - "qoder", "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "kilo", "droid", "gentle-shell", + "qoder", "qoder-cn", "workbuddy", "workbuddy-ai", "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "kilo", "droid", "gentle-shell", ]); }); diff --git a/gui/tests/locale-parity.test.ts b/gui/tests/locale-parity.test.ts index 6f52ac4b1b0..3a391baead6 100644 --- a/gui/tests/locale-parity.test.ts +++ b/gui/tests/locale-parity.test.ts @@ -175,6 +175,10 @@ const ZH_TW_KEEP_ENGLISH: ReadonlySet = new Set([ // Qoder is a product name, identical in every locale. "integrations.tab.qoder", "api.clientConfig.clientQoder", + // Qoder CN and the two WorkBuddy editions are product names too. + "integrations.tab.qoderCn", + "integrations.tab.workbuddy", + "integrations.tab.workbuddyAi", // Command Code is a product name, identical in every locale. "integrations.tab.commandcode", "api.clientConfig.clientCommandCode", diff --git a/scripts/test-layout/layout-clients.json b/scripts/test-layout/layout-clients.json index bf99d076825..9bf6607a9ab 100644 --- a/scripts/test-layout/layout-clients.json +++ b/scripts/test-layout/layout-clients.json @@ -1,4 +1,6 @@ { + "agent-clients.test.ts": "clients", + "workbuddy-selector-recovery.test.ts": "clients", "server-live-close.test.ts": "server", "link-diagnostics.test.ts": "clients", "link-tunnel-history.test.ts": "clients", diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 8ca677e5960..7b0786906cd 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -546,7 +546,7 @@ const commandRunners: Record = { }, config, port: live.port, - }, ["mcode", "pi", "raycast", "omo", "cline", "commandcode", "droid", "opencode", "kilo", "qoder"])); + }, ["mcode", "pi", "raycast", "omo", "cline", "commandcode", "droid", "opencode", "kilo", "qoder", "qoder-cn", "workbuddy", "workbuddy-ai"])); } catch (error) { console.warn(`Client integrations were not refreshed: ${error instanceof Error ? error.message : String(error)}`); } diff --git a/src/cli/export-command.ts b/src/cli/export-command.ts index 91f0f3e4930..00e5e9a7523 100644 --- a/src/cli/export-command.ts +++ b/src/cli/export-command.ts @@ -169,7 +169,7 @@ export async function handleExportCommand(argv: string[], deps: ExportCommandDep const spec = EXPORT_CLIENTS[client]; const root = await runtimeBaseUrl(deps); let built: { document: unknown; text: string }; - if (client === "raycast" || client === "droid" || client === "qoder") { + if (client === "raycast" || client === "droid" || client === "qoder" || client === "qoder-cn" || client === "workbuddy" || client === "workbuddy-ai") { // The dial address alone cannot distinguish a wildcard authenticated bind // from loopback. Let the live server resolve its admission/listener policy; // saved config can differ from the process serving this request. diff --git a/src/cli/help.ts b/src/cli/help.ts index 127338e3afe..cdd3ecd78d7 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -94,7 +94,7 @@ Usage: ocx api-key Alias of ocx access key ocx access External API keys and endpoint information ocx api Protocol paths: vocabulary, request-path preview, and policy - ocx export --client Print a client config wired to the running proxy (20 clients) + ocx export --client Print a client config wired to the running proxy (23 clients) ocx integration client Enable, disable, inspect or roll back a client integration ocx grok Grok Build model selection and apply ocx system Runtime settings, startup, sync, OpenCodex updates, and Codex CLI inspection diff --git a/src/cli/integration-plan-dto.ts b/src/cli/integration-plan-dto.ts index 850de05f1e5..d1004dffce6 100644 --- a/src/cli/integration-plan-dto.ts +++ b/src/cli/integration-plan-dto.ts @@ -27,6 +27,9 @@ export const FILE_INTEGRATION_CLIENTS = [ "kilo", "droid", "qoder", + "qoder-cn", + "workbuddy", + "workbuddy-ai", "gentle-shell", ] as const; @@ -57,6 +60,7 @@ const MISSING_STORE_DOCUMENT = /^[\x20-\x7e]{1,64}$/; const PLAN_CHANGE_KEYS = new Set(["kind", "path"]); const PLAN_PSEUDO_PATHS = new Set(["$snapshot", "$ownership", "$journal"]); const PLAN_SCHEMA_PATHS = new Set([ + "*", // A WorkBuddy legacy document is a top-level model array. "provider.opencodex", "providers.opencodex", "models.providers.opencodex", diff --git a/src/cli/registry.ts b/src/cli/registry.ts index a818c304cb3..7e7969429ac 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -518,8 +518,8 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ }, { name: "export", - usage: "ocx export --client [--json] [--out ] [--force]", - summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Command Code, Prime Agent, Aside, Raycast, omo, Cline, Kilo, Factory Droid, Qoder, Gentle Shell) wired to the running proxy.", + usage: "ocx export --client [--json] [--out ] [--force]", + summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Command Code, Prime Agent, Aside, Raycast, omo, Cline, Kilo, Factory Droid, Qoder, Qoder CN, WorkBuddy, WorkBuddy AI, Gentle Shell) wired to the running proxy.", details: [ "--json prints the generated document as JSON on stdout; use --out for the client's native format.", "--out writes the native config there and refuses to replace an existing file without --force.", diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts index a66e22dd15d..dc1171ad1d4 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -85,6 +85,7 @@ import { export type { CommandCodeGeneratedConfig, CommandCodeModelEntry, CommandCodeProviderBlock }; export { buildCommandCodeClientConfig, summarizeCommandCode, buildCommandCodeContribution, commandCodeProviderRoot }; +import { agentConfigPath, buildQoderCnClientConfig, buildQoderCnContribution, buildWorkbuddyClientConfig, buildWorkbuddyContribution, summarizeWorkbuddy, workbuddyApplyRefusal } from "./config-export/agent-clients"; import { buildQoderClientConfig, summarizeQoder, buildQoderContribution, qoderConfigPath } from "./config-export/qoder"; export { buildQoderClientConfig, qoderConfigPath, qoderHomeDir, type QoderGeneratedConfig } from "./config-export/qoder"; @@ -1401,6 +1402,17 @@ export const EXPORT_CLIENTS: Record = { buildContribution: buildQoderContribution, loopbackOnly: true, }, + "qoder-cn": { + id: "qoder-cn", filename: "qoder-cn-settings.json", destination: env => agentConfigPath("qoder-cn", env), + apiKeyEnv: "", exportHint: "Qoder CN uses a non-secret loopback placeholder.", format: "json", + build: buildQoderCnClientConfig, summarize: summarizeQoder, buildContribution: buildQoderCnContribution, loopbackOnly: true, + }, + ...Object.fromEntries((["workbuddy", "workbuddy-ai"] as const).map(id => [id, { + id, filename: `${id}-models.json`, destination: (env: NodeJS.ProcessEnv) => agentConfigPath(id, env), + apiKeyEnv: "", exportHint: "WorkBuddy uses a non-secret loopback placeholder.", format: "json" as const, + build: buildWorkbuddyClientConfig, summarize: summarizeWorkbuddy, validateApply: workbuddyApplyRefusal, + buildContribution: (ctx: ExportContext) => buildWorkbuddyContribution(id, ctx), loopbackOnly: true, + }])) as Record<"workbuddy" | "workbuddy-ai", ExportClientSpec>, opencode: { id: "opencode", filename: "opencode.json", diff --git a/src/clients/config-export/agent-clients.ts b/src/clients/config-export/agent-clients.ts new file mode 100644 index 00000000000..b67efc00ce8 --- /dev/null +++ b/src/clients/config-export/agent-clients.ts @@ -0,0 +1,96 @@ +import { homedir } from "node:os"; +import { isAbsolute, join } from "node:path"; +import { isLoopbackHostname, shouldInjectApiAuthHeader } from "../../codex/loopback-target"; +import { ClientPathError } from "../client-path"; +import { parseSegment } from "../../integrations/merge"; +import type { ExportContext, ExportClientId, ManagedContribution, OpencodeLaunchEnv } from "./contracts"; +import { LOOPBACK_API_KEY_PLACEHOLDER } from "./constants"; +import { normalizeExportModels, authoritativeContextWindow } from "./model-metadata"; +import { buildQoderClientConfig, buildQoderContribution } from "./qoder"; + +export function agentHomeDir(client: "qoder-cn" | "workbuddy" | "workbuddy-ai", env: OpencodeLaunchEnv = process.env, home = homedir()): string { + const key = client === "qoder-cn" ? "QODERCN_CONFIG_DIR" : "WORKBUDDY_CONFIG_DIR"; + // Both WorkBuddy editions use the same native override; the default roots stay separate. + const override = env[key]?.trim() || (client === "qoder-cn" ? undefined : env.CODEBUDDY_CONFIG_DIR?.trim()); + if (!override) return join(home, `.${client}`); + if (override === "~") return home; + if (override.startsWith("~/") || override.startsWith("~\\")) return join(home, override.slice(2)); + if (!isAbsolute(override)) throw new ClientPathError(`${key} must be an absolute path or start with ~.`); + return override; +} + +export function agentConfigPath(client: "qoder-cn" | "workbuddy" | "workbuddy-ai", env: OpencodeLaunchEnv = process.env, home = homedir()): string { + return join(agentHomeDir(client, env, home), client === "qoder-cn" ? "settings.json" : "models.json"); +} + +export function buildQoderCnClientConfig(ctx: ExportContext) { + return buildQoderClientConfig(ctx); +} +export function buildQoderCnContribution(ctx: ExportContext): ManagedContribution { + return { ...buildQoderContribution(ctx), clientId: "qoder-cn" }; +} + +function modelSelector(id: string): string | null { + const selector = `[id=${id}]`; + const parsed = parseSegment(selector); + if (parsed.kind !== "select" || parsed.criteria[0]?.value !== id) { + return null; + } + return selector; +} + +function workbuddyRows(ctx: ExportContext) { + return normalizeExportModels(ctx.models).map(model => { + return { + id: model.namespaced, + name: `OpenCodex: ${model.displayName ?? model.namespaced}`, + vendor: "OpenAI", + url: `${ctx.baseUrl.replace(/\/+$/, "")}/chat/completions`, + apiKey: LOOPBACK_API_KEY_PLACEHOLDER, + ...(authoritativeContextWindow(model.maxInputTokens) ? { maxInputTokens: authoritativeContextWindow(model.maxInputTokens) } : {}), + ...(authoritativeContextWindow(model.maxTokens) ? { maxOutputTokens: authoritativeContextWindow(model.maxTokens) } : {}), + ...(model.supportsTools === undefined ? {} : { supportsToolCall: model.supportsTools }), + ...(model.inputModalities === undefined ? {} : { supportsImages: model.inputModalities.includes("image") }), + ...(model.supportsReasoning === undefined ? {} : { supportsReasoning: model.supportsReasoning }), + }; + }); +} + +export function buildWorkbuddyClientConfig(ctx: ExportContext) { + if (shouldInjectApiAuthHeader(ctx.config) || !isLoopbackHostname(new URL(ctx.baseUrl).hostname)) { + throw new Error("WorkBuddy's placeholder API key requires a loopback-only listener."); + } + return { models: workbuddyRows(ctx) }; +} + +export function summarizeWorkbuddy(document: unknown) { + const rows = Array.isArray(document) ? document : (document as { models?: unknown[] } | undefined)?.models ?? []; + return { modelCount: rows.length, modelsWithoutLimits: rows.filter(row => !(row as { maxInputTokens?: number }).maxInputTokens).length }; +} + +/** Write-time validation must not prevent status, disable or snapshot recovery. */ +export function workbuddyApplyRefusal(ctx: ExportContext): string | null { + const models = workbuddyRows(ctx); + if (models.some(model => modelSelector(model.id) === null)) { + return "WorkBuddy model ID cannot be represented by a managed array selector."; + } + if (models.length === 0) return "WorkBuddy has no models in the selected OpenCodex catalog."; + const available = (ctx.document as { availableModels?: unknown } | undefined)?.availableModels; + if (Array.isArray(available) && available.length > 0 && models.some(model => !available.includes(model.id) && !available.includes(`custom-local:${model.id}`))) { + return "WorkBuddy availableModels hides OpenCodex models; remove the restriction in WorkBuddy before enabling this connection."; + } + return null; +} + +/** An unaddressable catalog contributes no desired rows; recorded ownership still protects recovery. */ +export function buildWorkbuddyContribution(clientId: ExportClientId, ctx: ExportContext): ManagedContribution { + const fragments: ManagedContribution["fragments"][number][] = []; + for (const model of workbuddyRows(ctx)) { + const selector = modelSelector(model.id); + // Apply/refresh refuse in validateApply. Status/disable must classify the + // saved fragment paths without depending on today's unaddressable catalog. + if (selector === null) return { clientId, fragments: [] }; + fragments.push({ path: [...(Array.isArray(ctx.document) ? [] : ["models"]), selector], value: model }); + } + return { clientId, fragments }; +} diff --git a/src/clients/config-export/contracts.ts b/src/clients/config-export/contracts.ts index 92337f2f194..dd173910e72 100644 --- a/src/clients/config-export/contracts.ts +++ b/src/clients/config-export/contracts.ts @@ -203,6 +203,9 @@ export type DroidReasoningDefaults = Record; export type ExportClientId = | "qoder" + | "qoder-cn" + | "workbuddy" + | "workbuddy-ai" | "opencode" | "pi" | "omp" @@ -262,6 +265,8 @@ export interface ExportClientSpec { * reasoning as the Grok managed block's non-loopback refusal. */ loopbackOnly: boolean; + /** Refuse apply/refresh without blocking status, disable or Undo. Pure and value-free. */ + validateApply?: (ctx: ExportContext) => string | null; /** * True when the destination file may carry comments and trailing commas * even though `format` is "json" and serialization stays pretty JSON. diff --git a/src/clients/config-export/qoder.ts b/src/clients/config-export/qoder.ts index aaccfe775a3..d814bf85291 100644 --- a/src/clients/config-export/qoder.ts +++ b/src/clients/config-export/qoder.ts @@ -1,7 +1,7 @@ import { homedir } from "node:os"; import { isAbsolute, join } from "node:path"; import { isLoopbackHostname, shouldInjectApiAuthHeader } from "../../codex/loopback-target"; -import { ClientPathError } from "../config-export"; +import { ClientPathError } from "../client-path"; import type { ExportContext, ManagedContribution, OpencodeLaunchEnv } from "./contracts"; import { LOOPBACK_API_KEY_PLACEHOLDER, OPENCODE_PROVIDER_ID } from "./constants"; import { authoritativeContextWindow, normalizeExportModels } from "./model-metadata"; diff --git a/src/integrations/catalog-refresh.ts b/src/integrations/catalog-refresh.ts index 3c0ddcbc32d..6b91856eb1a 100644 --- a/src/integrations/catalog-refresh.ts +++ b/src/integrations/catalog-refresh.ts @@ -12,7 +12,7 @@ import { /** Refresh only previously connected clients; a refused file never blocks its peers. */ export async function refreshOwnedCatalogIntegrations( input: Omit, - clientIds: readonly IntegrationClientId[] = ["pi", "aside", "raycast", "omo", "commandcode", "droid", "opencode", "kilo", "qoder", "gentle-shell"], + clientIds: readonly IntegrationClientId[] = ["pi", "aside", "raycast", "omo", "commandcode", "droid", "opencode", "kilo", "qoder", "qoder-cn", "workbuddy", "workbuddy-ai", "gentle-shell"], options: { refreshOnly?: boolean; admit?: () => boolean } = {}, ): Promise { // Client files are shared with the live proxy a sibling instance runs beside; their entries diff --git a/src/integrations/mutation-plan.ts b/src/integrations/mutation-plan.ts index e6a08ad94d7..284b93dbe56 100644 --- a/src/integrations/mutation-plan.ts +++ b/src/integrations/mutation-plan.ts @@ -132,6 +132,9 @@ export const DYNAMIC_SEGMENT = "*"; */ const CLIENT_MANAGED_PATHS = { qoder: [["providers", OPENCODE_PROVIDER_ID], ["modelConfigs", "customModels", DYNAMIC_SEGMENT]], + "qoder-cn": [["providers", OPENCODE_PROVIDER_ID], ["modelConfigs", "customModels", DYNAMIC_SEGMENT]], + workbuddy: [["models", DYNAMIC_SEGMENT], [DYNAMIC_SEGMENT]], + "workbuddy-ai": [["models", DYNAMIC_SEGMENT], [DYNAMIC_SEGMENT]], opencode: [["provider", OPENCODE_PROVIDER_ID], ["providers", OPENCODE_PROVIDER_ID]], pi: [["providers", OPENCODE_PROVIDER_ID]], "gentle-shell": [["providers", OPENCODE_PROVIDER_ID]], @@ -1004,6 +1007,10 @@ export function observeIntegration( const record = stored && stored.clientId === clientId && stored.configPath === configPath ? stored : null; + if (operation !== "disable") { + const refusal = exportSpec.validateApply?.({ ...exportContextOf(input), document: parsed }); + if (refusal) return { failed: observationFailure("unsafe", "unsafe", refusal) } as const; + } let contribution: ManagedContribution; try { contribution = buildIntegrationContribution(input, effective, parsed, record); diff --git a/src/integrations/registry.ts b/src/integrations/registry.ts index b53228fdaa5..4218e6e98b2 100644 --- a/src/integrations/registry.ts +++ b/src/integrations/registry.ts @@ -8,6 +8,7 @@ * * Design of record: devlog/_fin/260802_client_toggle_api/021 §1. */ +import { agentConfigPath, agentHomeDir } from "../clients/config-export/agent-clients"; import { homedir } from "node:os"; import { gentleShellPaths } from "../clients/gentle-shell-path"; import { assertDroidSettingsUnambiguous } from "./droid-settings"; @@ -288,6 +289,10 @@ export const INTEGRATION_CLIENTS: Record qoderHomeDir(env, home), }, + ...Object.fromEntries((["qoder-cn", "workbuddy", "workbuddy-ai"] as const).map(id => [id, { + id, configPath: (env = process.env, home = homedir()) => agentConfigPath(id, env, home), + detectDir: (env = process.env, home = homedir()) => agentHomeDir(id, env, home), + }])) as Record<"qoder-cn" | "workbuddy" | "workbuddy-ai", IntegrationClientSpec>, opencode: { id: "opencode", // These take `home` explicitly. The export registry's `destination` reads diff --git a/src/integrations/state.ts b/src/integrations/state.ts index e5b97cb7bff..ca491a640e8 100644 --- a/src/integrations/state.ts +++ b/src/integrations/state.ts @@ -135,7 +135,7 @@ export function qoderLegacyCatalogConflict( record: OwnershipRecord | null, contribution: ManagedContribution | null, ): boolean { - if (contribution?.clientId !== "qoder") return false; + if (contribution?.clientId !== "qoder" && contribution?.clientId !== "qoder-cn") return false; const owned = new Set((record?.fragmentPaths ?? []) .filter(path => Array.isArray(path) && path.every(key => typeof key === "string")) .map(path => readPath(parsed, path))); diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 91d51e128fd..9ba4d58d45e 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -331,8 +331,8 @@ export async function syncEnabledClientIntegrations( config, port, }, unattended - ? ["mcode", "pi", "aside", "raycast", "omo", "commandcode", "droid", "opencode", "kilo", "qoder"] - : ["mcode", "pi", "aside", "raycast", "omo", "cline", "commandcode", "droid", "opencode", "kilo", "qoder"], + ? ["mcode", "pi", "aside", "raycast", "omo", "commandcode", "droid", "opencode", "kilo", "qoder", "qoder-cn", "workbuddy", "workbuddy-ai"] + : ["mcode", "pi", "aside", "raycast", "omo", "cline", "commandcode", "droid", "opencode", "kilo", "qoder", "qoder-cn", "workbuddy", "workbuddy-ai"], ...unattended ? [{ refreshOnly: true, admit }] as const : [])); return out; diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts index 2b6586c9a94..515f524ce1a 100644 --- a/src/server/management/model-routes.ts +++ b/src/server/management/model-routes.ts @@ -561,9 +561,9 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise { expect(code).toBe(0); expect(order).toEqual(["catalog", "refresh"]); expect(refresh).toHaveBeenCalledTimes(1); - expect(refresh.mock.calls[0]![1]).toEqual(["mcode", "pi", "raycast", "omo", "cline", "commandcode", "droid", "opencode", "kilo", "qoder"]); + expect(refresh.mock.calls[0]![1]).toEqual(["mcode", "pi", "raycast", "omo", "cline", "commandcode", "droid", "opencode", "kilo", "qoder", "qoder-cn", "workbuddy", "workbuddy-ai"]); } finally { sync.mockRestore(); refresh.mockRestore(); aside.mockRestore(); log.mockRestore(); } diff --git a/tests/clients/agent-clients.test.ts b/tests/clients/agent-clients.test.ts new file mode 100644 index 00000000000..bfc6982b1b3 --- /dev/null +++ b/tests/clients/agent-clients.test.ts @@ -0,0 +1,97 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { buildClientConfigText, buildClientContribution, type ExportContext } from "../../src/clients/config-export"; +import { agentConfigPath, agentHomeDir, workbuddyApplyRefusal } from "../../src/clients/config-export/agent-clients"; +import { readIntegrationState } from "../../src/integrations/state"; +import { INTEGRATION_CLIENTS } from "../../src/integrations/registry"; +import { createIntegrationStateStore } from "../../src/integrations/store"; +import { applyIntegration, disableIntegration, refreshIntegration, restoreIntegration, type IntegrationWriteInput } from "../../src/integrations/writer"; +import { canonicalSchemaPath } from "../../src/integrations/mutation-plan"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const config = { hostname: "127.0.0.1", port: 10100, defaultProvider: "mock", providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } } } as OcxConfig; +const context: ExportContext = { config, baseUrl: "http://127.0.0.1:10100/v1", models: [ + { namespaced: "mock/a", provider: "mock", id: "a", maxInputTokens: 24000, maxTokens: 8000, supportsTools: true, supportsReasoning: true, inputModalities: ["text", "image"] }, + { namespaced: "mock/b", provider: "mock", id: "b" }, +] }; +const clients = ["qoder-cn", "workbuddy", "workbuddy-ai"] as const; +const roots: string[] = []; +afterEach(() => { roots.splice(0).forEach(removeTreeWithRetry); }); + +for (const id of clients) { + test(`${id} uses an independent native path and refuses relative overrides`, () => { + const home = join(tmpdir(), "agent-path-home"); + const filename = id === "qoder-cn" ? "settings.json" : "models.json"; + const key = id === "qoder-cn" ? "QODERCN_CONFIG_DIR" : "WORKBUDDY_CONFIG_DIR"; + expect(INTEGRATION_CLIENTS[id].configPath({}, home)).toBe(join(home, `.${id}`, filename)); + expect(agentConfigPath(id, { [key]: "~/custom" }, home)).toBe(join(home, "custom", filename)); + expect(agentHomeDir(id, { [key]: "~" }, home)).toBe(home); + expect(() => agentConfigPath(id, { [key]: "relative" }, home)).toThrow(key); + if (id !== "qoder-cn") expect(agentHomeDir(id, { WORKBUDDY_CONFIG_DIR: " ", CODEBUDDY_CONFIG_DIR: "~/legacy" }, home)).toBe(join(home, "legacy")); + }); + + test(`${id} refuses authenticated or remote placeholder export`, () => { + for (const hostname of ["0.0.0.0", "::"]) expect(() => buildClientConfigText(id, { ...context, config: { ...config, hostname } })).toThrow("loopback"); + expect(() => buildClientConfigText(id, { ...context, baseUrl: "https://example.com/v1" })).toThrow("loopback"); + }); + + for (const arrayRoot of id === "qoder-cn" ? [false] : [false, true]) { + test(`${id} preserves foreign settings through apply, refresh, disable and byte-exact Undo (${arrayRoot ? "array" : "object"})`, async () => { + const home = mkdtempSync(join(tmpdir(), "ocx-agent-client-")); roots.push(home); + const path = agentConfigPath(id, {}, home); mkdirSync(dirname(path), { recursive: true }); + const foreign = { id: "foreign/a", vendor: "Other", name: "User model", url: "http://localhost:12000/v1/chat/completions" }; + const original = id === "qoder-cn" ? { theme: "dark", modelConfigs: { customModels: [{ provider: "other", model: "mock/a" }] } } : arrayRoot ? [foreign] : { theme: "dark", models: [foreign] }; + const before = JSON.stringify(original, null, 4) + "\n"; writeFileSync(path, before); + const store = createIntegrationStateStore(join(home, "store")); + const input: IntegrationWriteInput = { clientId: id, config, port: 10100, models: context.models, home, env: {}, store }; + expect((await applyIntegration(input)).ok).toBe(true); + const opId = store.listOperations(id)[0]!.opId; + const read = () => JSON.parse(readFileSync(path, "utf8")); + const rows = (doc: any) => id === "qoder-cn" ? doc.providers.opencodex.models : arrayRoot ? doc : doc.models; + expect(rows(read())).toHaveLength(id === "qoder-cn" ? 2 : 3); + if (id !== "qoder-cn") expect(rows(read())[0]).toEqual(foreign); + expect((await refreshIntegration({ ...input, models: context.models.slice(0, 1) })).ok).toBe(true); + expect(rows(read())).toHaveLength(id === "qoder-cn" ? 1 : 2); + expect((await disableIntegration(input)).ok).toBe(true); + expect(read()).toEqual(original); + expect((await restoreIntegration({ ...input, opId, confirmDrift: true })).ok).toBe(true); + expect(readFileSync(path, "utf8")).toBe(before); + }); + } +} + +test("WorkBuddy exports canonical selectors, full chat URL and declared capabilities only", () => { + const doc = buildClientConfigText("workbuddy", context).document as { models: Record[] }; + expect(doc.models[0]).toMatchObject({ id: "mock/a", name: "OpenCodex: mock/a", vendor: "OpenAI", url: `${context.baseUrl}/chat/completions`, supportsToolCall: true, supportsImages: true, supportsReasoning: true, maxInputTokens: 24000, maxOutputTokens: 8000 }); + expect(doc.models[1]).not.toHaveProperty("supportsToolCall"); + expect(doc.models[1]).not.toHaveProperty("maxInputTokens"); + expect(buildClientContribution("workbuddy", { ...context, models: [{ namespaced: "mock/a]", provider: "mock", id: "a]" }] }).fragments).toEqual([]); + expect(workbuddyApplyRefusal({ ...context, document: { availableModels: ["foreign/a"] } })).toContain("availableModels"); + for (const client of clients) for (const fragment of buildClientContribution(client, context).fragments) { + const path = canonicalSchemaPath(client, fragment.path); + expect(path).not.toBeNull(); + expect(path).not.toContain("mock/a"); + } +}); + +test("WorkBuddy visibility restrictions refuse writes without breaking status or disable", () => { + const home = mkdtempSync(join(tmpdir(), "ocx-workbuddy-filter-")); roots.push(home); + const path = agentConfigPath("workbuddy", {}, home); mkdirSync(dirname(path), { recursive: true }); + const input: IntegrationWriteInput = { clientId: "workbuddy", config, port: 10100, models: context.models, home, env: {}, store: createIntegrationStateStore(join(home, "store")) }; + const before = JSON.stringify({ models: [], availableModels: ["foreign/a"] }); writeFileSync(path, before); + const refused = applyIntegration(input); + expect(refused.ok).toBe(false); + if (!refused.ok) expect(refused.message).toContain("availableModels"); + expect(readFileSync(path, "utf8")).toBe(before); + writeFileSync(path, JSON.stringify({ models: [] })); + expect(applyIntegration(input).ok).toBe(true); + const applied = JSON.parse(readFileSync(path, "utf8")); applied.availableModels = ["foreign/a"]; + writeFileSync(path, JSON.stringify(applied)); + expect(["current", "stale"]).toContain(readIntegrationState(input).state); + expect(refreshIntegration(input).ok).toBe(false); + expect(disableIntegration(input).ok).toBe(true); + expect(JSON.parse(readFileSync(path, "utf8"))).toEqual({ models: [], availableModels: ["foreign/a"] }); +}); diff --git a/tests/clients/integrations-state.test.ts b/tests/clients/integrations-state.test.ts index 2ccc03b4975..5dd335c2564 100644 --- a/tests/clients/integrations-state.test.ts +++ b/tests/clients/integrations-state.test.ts @@ -796,7 +796,7 @@ describe("installation detection is independent of config state", () => { describe("the loopback-only set is one fact, read through one seam", () => { test("identifies exactly the registered loopback-only clients", () => { const loopbackOnly = INTEGRATION_CLIENT_IDS.filter(id => isLoopbackOnly(id)); - expect(loopbackOnly).toEqual(["qoder", "pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "droid", "gentle-shell"]); + expect(loopbackOnly).toEqual(["qoder", "qoder-cn", "workbuddy", "workbuddy-ai", "pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "droid", "gentle-shell"]); }); test("the registry restates nothing — it reads the export spec", () => { diff --git a/tests/clients/qoder-client.test.ts b/tests/clients/qoder-client.test.ts index 698c37ec0d3..a44cecaba41 100644 --- a/tests/clients/qoder-client.test.ts +++ b/tests/clients/qoder-client.test.ts @@ -29,10 +29,10 @@ const context: ExportContext = { const roots: string[] = []; afterEach(() => { for (const root of roots.splice(0)) removeTreeWithRetry(root); }); -function fixture() { +function fixture(clientId: "qoder" | "qoder-cn" = "qoder") { const home = mkdtempSync(join(tmpdir(), "ocx-qoder-test-")); roots.push(home); - const path = qoderConfigPath({}, home); + const path = INTEGRATION_CLIENTS[clientId].configPath({}, home); mkdirSync(dirname(path), { recursive: true }); const original = { theme: "dark", providers: { other: { type: "custom" } }, @@ -41,7 +41,7 @@ function fixture() { const before = JSON.stringify(original, null, 4) + "\n"; writeFileSync(path, before); const input: IntegrationWriteInput = { - clientId: "qoder", config, port: 10100, models: context.models, + clientId, config, port: 10100, models: context.models, home, env: {}, store: createIntegrationStateStore(join(home, "store")), }; return { path, original, before, input, read: () => JSON.parse(readFileSync(path, "utf8")) }; @@ -225,37 +225,39 @@ describe("Qoder client integration", () => { } }); - const unowned = { provider: "opencodex", model: "mock/a", apiKey: "user-placeholder", displayName: "mine" }; + for (const clientId of ["qoder", "qoder-cn"] as const) { + const unowned = { provider: "opencodex", model: "mock/a", apiKey: "user-placeholder", displayName: "mine" }; - test.each([false, true])("an unowned opencodex row for an exported model refuses before writing (recorded=%s)", recorded => { - const f = fixture(); - if (recorded) expect(applyIntegration(f.input).ok).toBe(true); - const document = f.read(); - document.modelConfigs.customModels.push(unowned); - const before = JSON.stringify(document, null, 2); - writeFileSync(f.path, before); - const records = JSON.stringify(f.input.store!.readRecords()); - expect(readIntegrationState(f.input)).toMatchObject({ state: "conflict", reason: "unowned-key" }); - for (const operation of ["apply", "overwrite", "disable"] as const) { - expect(previewIntegration(f.input, { operation })).toMatchObject({ canApply: false, refusalReason: "conflict" }); - } - const writes = recorded - ? [refreshIntegration(f.input), applyIntegration(f.input), overwriteIntegration(f.input), disableIntegration(f.input)] - : [applyIntegration(f.input), overwriteIntegration(f.input)]; - for (const result of writes) expect(result).toMatchObject({ ok: false, reason: "conflict", message: expect.stringContaining("modelConfigs.customModels") }); - expect(readFileSync(f.path, "utf8")).toBe(before); - expect(JSON.stringify(f.input.store!.readRecords())).toBe(records); - }); + test.each([false, true])(`${clientId}: an unowned opencodex row for an exported model refuses before writing (recorded=%s)`, recorded => { + const f = fixture(clientId); + if (recorded) expect(applyIntegration(f.input).ok).toBe(true); + const document = f.read(); + document.modelConfigs.customModels.push(unowned); + const before = JSON.stringify(document, null, 2); + writeFileSync(f.path, before); + const records = JSON.stringify(f.input.store!.readRecords()); + expect(readIntegrationState(f.input)).toMatchObject({ state: "conflict", reason: "unowned-key" }); + for (const operation of ["apply", "overwrite", "disable"] as const) { + expect(previewIntegration(f.input, { operation })).toMatchObject({ canApply: false, refusalReason: "conflict" }); + } + const writes = recorded + ? [refreshIntegration(f.input), applyIntegration(f.input), overwriteIntegration(f.input), disableIntegration(f.input)] + : [applyIntegration(f.input), overwriteIntegration(f.input)]; + for (const result of writes) expect(result).toMatchObject({ ok: false, reason: "conflict", message: expect.stringContaining("modelConfigs.customModels") }); + expect(readFileSync(f.path, "utf8")).toBe(before); + expect(JSON.stringify(f.input.store!.readRecords())).toBe(records); + }); - test("opencodex rows for other models and other providers' rows for exported models do not conflict", () => { - const f = fixture(); - const document = f.read(); - document.modelConfigs.customModels.push({ ...unowned, model: "mock/z" }, { ...unowned, provider: "elsewhere" }); - writeFileSync(f.path, JSON.stringify(document)); - expect(readIntegrationState(f.input)).toMatchObject({ state: "absent" }); - expect(applyIntegration(f.input)).toMatchObject({ ok: true, state: "current" }); - expect(f.read().modelConfigs.customModels).toEqual(document.modelConfigs.customModels); - expect(disableIntegration(f.input).ok).toBe(true); - expect(f.read()).toEqual(document); - }); + test(`${clientId}: opencodex rows for other models and other providers' rows for exported models do not conflict`, () => { + const f = fixture(clientId); + const document = f.read(); + document.modelConfigs.customModels.push({ ...unowned, model: "mock/z" }, { ...unowned, provider: "elsewhere" }); + writeFileSync(f.path, JSON.stringify(document)); + expect(readIntegrationState(f.input)).toMatchObject({ state: "absent" }); + expect(applyIntegration(f.input)).toMatchObject({ ok: true, state: "current" }); + expect(f.read().modelConfigs.customModels).toEqual(document.modelConfigs.customModels); + expect(disableIntegration(f.input).ok).toBe(true); + expect(f.read()).toEqual(document); + }); + } }); diff --git a/tests/clients/sync-client-integrations-unattended.test.ts b/tests/clients/sync-client-integrations-unattended.test.ts index dfb46107d10..eb10631c52e 100644 --- a/tests/clients/sync-client-integrations-unattended.test.ts +++ b/tests/clients/sync-client-integrations-unattended.test.ts @@ -79,7 +79,7 @@ describe("unattended native and file client admission", () => { expect(await syncEnabledClientIntegrations(12345, config, deps(fetch), unattended)).toEqual([]); expect(fetch).not.toHaveBeenCalled(); expect(writer).not.toHaveBeenCalled(); const args = refresh.mock.calls[0]!; - expect(args[1]).toEqual(["mcode", "pi", "aside", "raycast", "omo", "commandcode", "droid", "opencode", "kilo", "qoder"]); + expect(args[1]).toEqual(["mcode", "pi", "aside", "raycast", "omo", "commandcode", "droid", "opencode", "kilo", "qoder", "qoder-cn", "workbuddy", "workbuddy-ai"]); expect(args[2]?.refreshOnly).toBe(true); expect(args[2]?.admit?.()).toBe(true); config.runtimeRole = "hub"; persist(); expect(args[2]?.admit?.()).toBe(false); config.unauthenticatedLoopbackListener = { enabled: true, port: 12346 }; persist(); diff --git a/tests/clients/sync-client-integrations.test.ts b/tests/clients/sync-client-integrations.test.ts index f280cd2c30a..a2cd7a035ff 100644 --- a/tests/clients/sync-client-integrations.test.ts +++ b/tests/clients/sync-client-integrations.test.ts @@ -65,7 +65,7 @@ describe("ocx sync fans out to enabled native clients and owned file integration expect(fn).toContain("grokIntegrationEnabled(config)"); expect(fn).toContain("claudeDesktopIntegrationEnabled(config)"); - expect(fn).toContain('["mcode", "pi", "aside", "raycast", "omo", "cline", "commandcode", "droid", "opencode", "kilo", "qoder"]'); + expect(fn).toContain('["mcode", "pi", "aside", "raycast", "omo", "cline", "commandcode", "droid", "opencode", "kilo", "qoder", "qoder-cn", "workbuddy", "workbuddy-ai"]'); expect(fn).toContain("refreshOwnedCatalogIntegrations"); // Native clients keep their catches; the owned catalog helper isolates file clients. expect(fn.match(/catch \(error\)/g)?.length).toBe(2); @@ -910,7 +910,7 @@ test("the direct ocx sync command refreshes owned clients including OpenCode and expect(command).toContain("refreshOwnedCatalogIntegrations"); // Command Code joined this fan-out with its integration: a sync must refresh its // provider block too, or the client keeps a stale catalog until the next apply. - expect(command).toContain('["mcode", "pi", "raycast", "omo", "cline", "commandcode", "droid", "opencode", "kilo", "qoder"]'); + expect(command).toContain('["mcode", "pi", "raycast", "omo", "cline", "commandcode", "droid", "opencode", "kilo", "qoder", "qoder-cn", "workbuddy", "workbuddy-ai"]'); expect(command).toContain("refreshAsideProfilesThroughServer"); expect(command.indexOf("syncModelsToCodex")).toBeLessThan(command.indexOf("refreshOwnedCatalogIntegrations")); expect(command).toContain('synced.status !== "refused"'); diff --git a/tests/clients/workbuddy-selector-recovery.test.ts b/tests/clients/workbuddy-selector-recovery.test.ts new file mode 100644 index 00000000000..52f6d29c76e --- /dev/null +++ b/tests/clients/workbuddy-selector-recovery.test.ts @@ -0,0 +1,76 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { agentConfigPath } from "../../src/clients/config-export/agent-clients"; +import { previewIntegration } from "../../src/integrations/mutation-plan"; +import { readIntegrationState } from "../../src/integrations/state"; +import { createIntegrationStateStore } from "../../src/integrations/store"; +import { applyIntegration, disableIntegration, refreshIntegration, restoreIntegration, type IntegrationWriteInput } from "../../src/integrations/writer"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const config = { hostname: "127.0.0.1", port: 10100, defaultProvider: "mock", providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1", allowPrivateNetwork: true } } } as OcxConfig; +const model = (id: string) => ({ namespaced: `mock/${id}`, provider: "mock", id }); +const roots: string[] = []; +afterEach(() => { for (const root of roots.splice(0)) removeTreeWithRetry(root); }); + +function fixture(clientId: "workbuddy" | "workbuddy-ai", arrayRoot: boolean) { + const home = mkdtempSync(join(tmpdir(), "ocx-workbuddy-selector-")); roots.push(home); + const path = agentConfigPath(clientId, {}, home); mkdirSync(dirname(path), { recursive: true }); + const foreign = { id: "mock/foreign", name: "Keep", vendor: "Other" }; + const original = arrayRoot ? [foreign] : { theme: "dark", models: [foreign] }; + const before = JSON.stringify(original, null, 4) + "\n"; writeFileSync(path, before); + const store = createIntegrationStateStore(join(home, "store")); + const input: IntegrationWriteInput = { clientId, config, port: 10100, models: [model("a")], home, env: {}, store }; + // A newly visible valid ID collides with a foreign row as well as the invalid ID. + const invalid = { ...input, models: [model("a]"), model("foreign")] }; + const read = () => JSON.parse(readFileSync(path, "utf8")); + const rows = () => arrayRoot ? read() : read().models; + return { path, original, before, store, input, invalid, read, rows }; +} + +for (const client of ["workbuddy", "workbuddy-ai"] as const) for (const arrayRoot of [false, true]) { + test(`${client} (${arrayRoot ? "array" : "object"}) refuses an unaddressable catalog without writes or thrown status`, () => { + const f = fixture(client, arrayRoot); + expect(readIntegrationState(f.invalid).state).toBe("absent"); + const plan = previewIntegration(f.invalid, { operation: "apply" }); + expect(plan.canApply).toBe(false); + const refused = applyIntegration(f.invalid); + expect(refused.ok).toBe(false); + if (!refused.ok) { + expect(refused.reason).toBe("unsafe"); + expect(refused.message).toContain("selector"); + expect(refused.message).not.toContain("mock/a]"); + } + expect(readFileSync(f.path, "utf8")).toBe(f.before); + expect(f.store.listOperations(client)).toEqual([]); + }); + + test(`${client} (${arrayRoot ? "array" : "object"}) disables recorded rows despite today's unaddressable catalog`, () => { + const f = fixture(client, arrayRoot); + expect(applyIntegration(f.input).ok).toBe(true); + expect(readIntegrationState(f.invalid).state).toBe("stale"); + const applied = readFileSync(f.path, "utf8"); + expect(refreshIntegration(f.invalid).ok).toBe(false); + expect(readFileSync(f.path, "utf8")).toBe(applied); + expect(previewIntegration(f.invalid, { operation: "disable" }).canApply).toBe(true); + expect(disableIntegration(f.invalid).ok).toBe(true); + expect(f.read()).toEqual(f.original); + expect(readIntegrationState(f.invalid).state).toBe("absent"); + }); + + test(`${client} (${arrayRoot ? "array" : "object"}) retains foreign-edit protection and byte-exact Undo with an unaddressable catalog`, () => { + const f = fixture(client, arrayRoot); + expect(applyIntegration(f.input).ok).toBe(true); + const opId = f.store.listOperations(client)[0]!.opId; + const document = f.read(); + (arrayRoot ? document : document.models).find((row: { id: string }) => row.id === "mock/a").name = "User edit"; + const edited = JSON.stringify(document); writeFileSync(f.path, edited); + expect(readIntegrationState(f.invalid).state).toBe("conflict"); + expect(disableIntegration(f.invalid).ok).toBe(false); + expect(readFileSync(f.path, "utf8")).toBe(edited); + expect(restoreIntegration({ ...f.invalid, opId, confirmDrift: true }).ok).toBe(true); + expect(readFileSync(f.path, "utf8")).toBe(f.before); + }); +} diff --git a/tests/config/client-config-export-new-clients.test.ts b/tests/config/client-config-export-new-clients.test.ts index 62bce86ab49..33b393b40c6 100644 --- a/tests/config/client-config-export-new-clients.test.ts +++ b/tests/config/client-config-export-new-clients.test.ts @@ -64,7 +64,7 @@ describe("no secret reaches a client config", () => { // credential wiring is deliberately deferred from those initial generated // integrations -- omo and Gentle Shell reuse Pi's builder, which emits no headers at all. const loopbackOnly = EXPORT_CLIENT_IDS.filter(id => EXPORT_CLIENTS[id].loopbackOnly); - expect(loopbackOnly).toEqual(["qoder", "pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "droid", "gentle-shell"]); + expect(loopbackOnly).toEqual(["qoder", "qoder-cn", "workbuddy", "workbuddy-ai", "pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "droid", "gentle-shell"]); }); test("every client that is not loopback-only carries the header on a remote bind", () => { @@ -85,7 +85,7 @@ describe("no secret reaches a client config", () => { test("even a non-loopback bind never serializes the key", () => { for (const id of EXPORT_CLIENT_IDS) { - if (id === "qoder") { + if (id === "qoder" || id === "qoder-cn" || id === "workbuddy" || id === "workbuddy-ai") { expect(() => buildClientConfigText(id, ctx(REMOTE))).toThrow("loopback"); continue; } diff --git a/tests/config/client-config-export.test.ts b/tests/config/client-config-export.test.ts index fc79e1732ea..c5fd8b90653 100644 --- a/tests/config/client-config-export.test.ts +++ b/tests/config/client-config-export.test.ts @@ -850,8 +850,8 @@ describe("hub-resolved Fast exports", () => { }); describe("EXPORT_CLIENTS registry", () => { - test("covers exactly the nineteen file-toggle clients", () => { - expect(EXPORT_CLIENT_IDS).toEqual(["qoder", "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "kilo", "droid", "gentle-shell"]); + test("covers exactly the twenty-three file-toggle clients", () => { + expect(EXPORT_CLIENT_IDS).toEqual(["qoder", "qoder-cn", "workbuddy", "workbuddy-ai", "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "kilo", "droid", "gentle-shell"]); for (const id of EXPORT_CLIENT_IDS) expect(isExportClientId(id)).toBe(true); // The exception clients keep their own surfaces and are not export clients. expect(isExportClientId("claude-desktop")).toBe(false); diff --git a/tests/fixtures/import-cycle-baseline.json b/tests/fixtures/import-cycle-baseline.json index 48711f0273f..56a07bbcbd8 100644 --- a/tests/fixtures/import-cycle-baseline.json +++ b/tests/fixtures/import-cycle-baseline.json @@ -1,7 +1,5 @@ { "cyclic": [ - "src/clients/config-export.ts", - "src/clients/config-export/qoder.ts", "src/codex/account-lifecycle.ts", "src/codex/auth-api.ts", "src/codex/auth-api/account-list.ts", diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 9f574a78445..a656b15a848 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -199,7 +199,7 @@ "kiro-auto-selection.test.ts": "providers/kiro", "kiro-quota-metrics.test.ts": "providers/kiro", "management-provider-request-pacing.test.ts": "server", "desktop-supervised-restart.test.ts": "clients", "cli-restart-handoff.test.ts": "cli", "restart-replacement.test.ts": "server", - "deepseek-artifact-tool-schema.test.ts": "providers", "client-config-export-output-limit.test.ts": "config", "qoder-client.test.ts": "clients", + "deepseek-artifact-tool-schema.test.ts": "providers", "client-config-export-output-limit.test.ts": "config", "qoder-client.test.ts": "clients", "agent-clients.test.ts": "clients", "workbuddy-selector-recovery.test.ts": "clients", "codex-account-clear-paused.test.ts": "codex-integration", "responses-skills-snapshot.test.ts": "responses", "openai-chat-serialized-tool-call-scaling.test.ts": "adapters/openai", "openai-chat-nonce-tool-call-content.test.ts": "adapters/openai", diff --git a/tests/gui/integrations-invariants.test.ts b/tests/gui/integrations-invariants.test.ts index 8a91ab971d2..f2d3830c12f 100644 --- a/tests/gui/integrations-invariants.test.ts +++ b/tests/gui/integrations-invariants.test.ts @@ -99,7 +99,7 @@ describe("the client registries cannot drift apart", () => { const guiRouting = await import("../../gui/src/app-routing"); const expected = [...EXPORT_CLIENT_IDS].sort(); - expect(expected).toHaveLength(20); + expect(expected).toHaveLength(23); expect([...INTEGRATION_CLIENT_IDS].sort()).toEqual(expected); expect([...gui.CLIENTS].sort()).toEqual(expected); @@ -252,6 +252,9 @@ describe("the journal is metadata, never a copy of the file", () => { describe("every client survives a full lifecycle", () => { /** A pre-existing user document in each client's own format. */ const SEED: Record = { + "qoder-cn": '{"theme":"dark"}\n', + workbuddy: '{"models":[{"id":"mine","vendor":"Other"}]}\n', + "workbuddy-ai": '[{"id":"mine","vendor":"Other"}]\n', qoder: '{"providers":{"mine":{"api":"http://keep-me"}},"theme":"dark"}\n', cline: '{"version":1,"modes":{},"providers":{"mine":{"settings":{"provider":"mine"},"updatedAt":"2026-01-01T00:00:00.000Z","tokenSource":"manual"}}}\n', commandcode: '{\n "provider": {\n "mine": { "name": "Keep Me", "api": "openai-completions", "baseURL": "http://keep-me/v1", "apiKey": "test-key", "models": {} }\n }\n}\n', @@ -283,6 +286,8 @@ describe("every client survives a full lifecycle", () => { /** Where the seed's user-owned entry lives when the seed is a sequence. */ const USER_ELEMENT: Partial> = { raycast: ["providers", "[id=lmstudio]"], + workbuddy: ["models", "[id=mine]"], + "workbuddy-ai": ["[id=mine]"], droid: ["customModels", "[model=local]"], }; From 816e84b1df1a1e0f76f71f1cea3aa82a953a157a Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 26 Oct 2026 22:31:37 +0900 Subject: [PATCH 090/101] fix(combo): reject buffered required-tool false successes (#7741) Carries the buffered half of #7711 onto dev for #7456. A buffered native combo child that ignores a required tool_choice now fails with required_tool_contract_unsatisfied (502, fallback-eligible), or 400 with retry suppression after a hosted effect. Streaming enforcement remains open. Co-authored-by: chenyb999-zhcn <259507278+chenyb999-zhcn@users.noreply.github.com> --- .../content/docs/reference/proxy-formats.md | 15 + scripts/test-layout/layout.json | 2 +- src/bridge/errors.ts | 3 + src/lib/errors.ts | 2 + .../responses/combo-required-tool-contract.ts | 103 +++ src/server/responses/passthrough-delivery.ts | 42 +- .../transports/responses-combo-boundaries.md | 24 + structure/transports/responses-failover.md | 2 +- structure/transports/responses.md | 2 +- tests/fixtures/test-layout-expected.json | 1 + tests/helpers/responses-core-source.ts | 2 + ...erver-combo-required-tool-contract.test.ts | 683 ++++++++++++++++++ 12 files changed, 876 insertions(+), 5 deletions(-) create mode 100644 src/server/responses/combo-required-tool-contract.ts create mode 100644 tests/server/server-combo-required-tool-contract.test.ts diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index d0b47e2e9ed..61eb7839155 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -20,6 +20,21 @@ response safety still happen at the proxy boundary. Configure the listener and a [Configuration](/reference/configuration/); use [Combos](/guides/combos/) when one public model id should select among several targets. +## Required tools on buffered native combo routes + +A combo checks buffered native `openai-responses` JSON results when `tool_choice` is `required` +or selects a named function/custom tool. A completed result without the required call becomes +HTTP 502 with `error.code: "required_tool_contract_unsatisfied"`. The combo may try another eligible +target before delivering the response, subject to its existing fallback policy and send budget. +If no eligible target succeeds, the client receives the typed error instead of a false completion. +A hosted call for a declared capability can satisfy `required`; a mismatched hosted call returns +HTTP 400 with the same error code and `x-should-retry: false`, since the hosted action may already +have executed. No fallback occurs in that case. Usage remains recorded. +Auto/none choices, incomplete results and unknown tool contracts keep their existing behavior. +This guard covers buffered native combo children. Streaming required-tool enforcement and direct +native routes are separate scopes; visible output is never replayed. Translated Chat routes have +their own guard, described next. + ## Required tools on translated Chat routes A translated OpenAI Chat response must contain a complete selected client tool call when diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 32ffbd92f55..bc228afaffb 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1662,7 +1662,7 @@ "server-combo-failover-e2e.test.ts": "server", "server-combo-held-response.test.ts": "server", "server-combo-reasoning-replay-eligibility.test.ts": "server", - "server-combo-tool-loop-continuation.test.ts": "server", + "server-combo-tool-loop-continuation.test.ts": "server", "server-combo-required-tool-contract.test.ts": "server", "server-combo-zero-output-failover.test.ts": "server", "server-google-antigravity-oauth-401-replay.test.ts": "server", "server-antigravity-web-search-validation.test.ts": "server", "server-google-antigravity-oauth-429-budget.test.ts": "server", diff --git a/src/bridge/errors.ts b/src/bridge/errors.ts index 196680f18c6..63503160849 100644 --- a/src/bridge/errors.ts +++ b/src/bridge/errors.ts @@ -15,6 +15,7 @@ import { isModelNotInPlanRefusal, markVerifiedModelNotInPlanResponse, MODEL_NOT_IN_PLAN_CODE, + REQUIRED_TOOL_CONTRACT_CODE, SPEND_LEDGER_STORAGE_UNAVAILABLE_CODE, UNDECLARED_TOOL_CALL_ERROR_CODE, type OcxErrorPayload, @@ -57,6 +58,7 @@ export function formatErrorResponse( // survives so the Anthropic translator can classify it instead of folding it into a // retryable overload (and so clients see the SSE path's rejection identity). if (options?.code === UNDECLARED_TOOL_CALL_ERROR_CODE) error.code = options.code; + if (options?.code === REQUIRED_TOOL_CONTRACT_CODE && error.code !== CYBER_POLICY_ERROR_CODE) error.code = options.code; // A plan refusal keeps its wire code so clients can branch on it. The code is not provenance: // run-turn combo children pass an adapter event's own code here, so only the boundary that // verified the refusal marks the envelope (`markVerifiedModelNotInPlanResponse`). @@ -87,6 +89,7 @@ export function formatErrorResponse( // and no wait of its own does not stop a client that retries every 429 by default. const refusal = isReplayRefusalCode(error.code) && replayBlocked; if (refusal) applyReplayRefusalClientHeaders(headers); + if (error.code === REQUIRED_TOOL_CONTRACT_CODE && finalStatus === 400) applyReplayRefusalClientHeaders(headers); const response = new Response(JSON.stringify({ error }), { status: finalStatus, headers, diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 1cd5e102a45..1bdcae0477f 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -25,6 +25,8 @@ export const SPEND_LEDGER_STORAGE_UNAVAILABLE_CODE = "spend_ledger_storage_unava * envelope formatter decides whether the identity survives serialization. */ export const UNDECLARED_TOOL_CALL_ERROR_CODE = "undeclared_tool_call"; +/** A buffered combo child completed without the call explicitly required by tool_choice. */ +export const REQUIRED_TOOL_CONTRACT_CODE = "required_tool_contract_unsatisfied"; /** * Wire code for a provider's model-scoped plan refusal (#7684): the account's plan does not diff --git a/src/server/responses/combo-required-tool-contract.ts b/src/server/responses/combo-required-tool-contract.ts new file mode 100644 index 00000000000..edf32ca6a63 --- /dev/null +++ b/src/server/responses/combo-required-tool-contract.ts @@ -0,0 +1,103 @@ +import { REQUIRED_TOOL_CONTRACT_CODE } from "../../lib/errors"; +import { applyReplayRefusalClientHeaders, isNonReplayableResponse, markResponseNonReplayable } from "../../lib/upstream-retry"; +import { clientWireOf } from "../inference/client-wire"; +import { isPreinspectedJsonResponse, markPreinspectedJsonResponse } from "../relay"; +import { isAuthorizedProviderExecutedCall } from "../responses-undeclared-tool-guard"; +import { carryResponseToolOwnership, responseToolOwnership } from "./response-tool-ownership"; + +export type RequiredToolContract = + | { kind: "any"; hostedCallTypes: ReadonlySet } + | { kind: "named"; name: string }; + +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"], + ["image_generation", "image_generation_call"], ["image_gen", "image_generation_call"], + ["mcp", "mcp_call"], ["computer_use", "computer_call"], + ["computer_use_preview", "computer_call"], +]); + +/** Unknown declarations abstain: their successful output spelling is not ours to guess. */ +export function requiredToolContractOf(rawBody: unknown): RequiredToolContract | undefined { + if (!rawBody || typeof rawBody !== "object" || Array.isArray(rawBody)) return; + const { tools, tool_choice: choice } = rawBody as Record; + if (!Array.isArray(tools) || tools.length === 0) return; + if (choice === "required") { + const hostedCallTypes = new Set(); + for (const tool of tools) { + if (!tool || typeof tool !== "object" || Array.isArray(tool)) return; + const type = (tool as Record).type; + if (type === "function" || type === "custom") continue; + const callType = typeof type === "string" ? hostedCalls.get(type) : undefined; + if (!callType) return; + hostedCallTypes.add(callType); + } + return { kind: "any", hostedCallTypes }; + } + if (choice && typeof choice === "object" && !Array.isArray(choice)) { + const named = choice as Record; + if ((named.type === "function" || named.type === "custom") + && typeof named.name === "string" && named.name.trim()) { + return { kind: "named", name: named.name }; + } + } +} + +/** Only a parsed completed output array can prove a missing required call. */ +export async function judgeBufferedRequiredTool(response: Response, requirement: RequiredToolContract): Promise { + const text = await response.text(); + let payload: Record | undefined; + try { + const parsed: unknown = JSON.parse(text); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) payload = parsed as Record; + } catch { /* An opaque body is not evidence of a required-tool violation. */ } + const output = payload?.output; + if (payload?.status === "completed" && Array.isArray(output)) { + const calls = output.filter((item): item is Record => + !!item && typeof item === "object" && !Array.isArray(item)); + const ownership = responseToolOwnership(response); + const providerExecuted = (call: Record) => + ownership !== undefined && isAuthorizedProviderExecutedCall(call, ownership); + const satisfied = calls.some(call => { + if (requirement.kind === "any" && requirement.hostedCallTypes.has(String(call.type))) return true; + if (providerExecuted(call)) return false; + if (call.type !== "function_call" && call.type !== "custom_tool_call") return false; + // Name-less calls cannot prove a selected-name mismatch. + return requirement.kind === "any" || typeof call.name !== "string" || call.name === requirement.name; + }); + if (!satisfied) { + const hostedEffect = calls.some(call => providerExecuted(call) + || (typeof call.type === "string" && call.type.endsWith("_call") + && call.type !== "function_call" && call.type !== "custom_tool_call")); + const failure = Response.json({ + error: { + type: "upstream_error", code: REQUIRED_TOOL_CONTRACT_CODE, + message: "Target completed the response without the tool call required by tool_choice", + }, + ...(payload.usage !== undefined ? { usage: payload.usage } : {}), + }, { status: hostedEffect ? 400 : 502 }); + // Hosted/unknown call items can already have executed remotely. Report the violation, + // but never buy another execution merely because the required client call is missing. + if (hostedEffect) { + markResponseNonReplayable(failure); + applyReplayRefusalClientHeaders(failure.headers); + } + return failure; + } + } + const headers = new Headers(response.headers); + headers.delete("content-encoding"); + headers.delete("content-length"); + const rebuilt = new Response(text, { status: response.status, statusText: response.statusText, headers }); + if (isPreinspectedJsonResponse(response)) markPreinspectedJsonResponse(rebuilt); + return carryResponseToolOwnership(response, rebuilt); +} + +/** Native buffered Responses only; translated Chat and runTurn retain their terminal owners. */ +export async function guardBufferedComboRequiredTool(response: Response, rawBody: unknown, adapter?: string): Promise { + if (!response.ok || adapter !== "openai-responses" || clientWireOf(response) !== undefined + || isNonReplayableResponse(response) + || !(response.headers.get("content-type") ?? "").includes("application/json")) return response; + const requirement = requiredToolContractOf(rawBody); + return requirement ? judgeBufferedRequiredTool(response, requirement) : response; +} diff --git a/src/server/responses/passthrough-delivery.ts b/src/server/responses/passthrough-delivery.ts index 925852f3fe4..60d07e53ff2 100644 --- a/src/server/responses/passthrough-delivery.ts +++ b/src/server/responses/passthrough-delivery.ts @@ -1,5 +1,7 @@ import { wasPermanentAccountRefusal } from "../../oauth/account-auto-pause"; import { isNativeControlResponse } from "./native-response-control"; +import { guardBufferedComboRequiredTool } from "./combo-required-tool-contract"; +import { attachResponseToolOwnership } from "./response-tool-ownership"; import { createOutboundCredentialMask, createTerminalErrorRedactionBlockRewrite } from "./terminal-error-redaction"; import type { ResponsesRequestContext, ResponsesAdmissionState } from "./core-options"; import type { PreparedResponsesRequest } from "./request-prepare"; @@ -148,6 +150,8 @@ import { inspectResponseLogJson, inspectResponseLogSsePayloadParsed, noteUpstreamRequestId, + recordKeyWireAttemptUsage, + usageFromResponsesPayload, } from "../request-log"; import { restoreRoutedCustomCallsInJson } from "../../responses/custom-tool-compat"; import { restoreRoutedToolSearchCallsInJson } from "../../responses/tool-search-compat"; @@ -447,6 +451,15 @@ export async function deliverPassthroughResponse( const { requestBindings } = transportState; let upstreamResponse = nativeExchange.upstreamResponse; + // Judge the restored native payload before display projection can erase hosted-call evidence. + const requiredToolFailure = async (text: string): Promise => { + if (!options.comboAttempt || clientRequestedStream === true || isNonReplayableResponse(upstreamResponse)) return; + const candidate = attachResponseToolOwnership(new Response(text, { + status: upstreamResponse.status, headers: { "content-type": "application/json" }, + }), providerExecutedCallTypes); + const verdict = await guardBufferedComboRequiredTool(candidate, parsed._rawBody, route.provider.adapter); + return verdict.ok ? undefined : verdict; + }; const canonicalBufferedJson = clientRequestedStream !== true && isCanonicalOpenAiForwardProvider(route.provider); const originalContentType = upstreamResponse.headers.get("content-type"); @@ -887,7 +900,7 @@ export async function deliverPassthroughResponse( createTerminalErrorRedactionBlockRewrite(nativeExchange.request.headers, maskCredential), rememberPlaintextBlock, // Display-only projection must follow every continuation-cache observer. - isLocalCodexImageClient(req.headers, options.admission?.kind, options.inboundWire) + (!canonicalBufferedJson || !options.comboAttempt) && isLocalCodexImageClient(req.headers, options.admission?.kind, options.inboundWire) ? createHostedImageDisplayRewrite() : undefined, ].filter((rewrite): rewrite is NonNullable => rewrite !== undefined); @@ -1030,6 +1043,29 @@ export async function deliverPassthroughResponse( }; if (signal.aborted) return cancelAfterValidation(); + const requiredFailure = await requiredToolFailure(JSON.stringify(client.terminal.response)); + if (requiredFailure) { + const usage = usageFromResponsesPayload(client.terminal.response.usage); + if (usage && !logCtx.usageFromBridge) { + if (!recordKeyWireAttemptUsage(logCtx, usage)) { + logCtx.usage = usage; + if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage; + } + logCtx.usageWireParsed = true; + } + return requiredFailure; + } + if (signal.aborted) return cancelAfterValidation(); + let clientJson = JSON.stringify(client.terminal.response); + if (options.comboAttempt && isLocalCodexImageClient(req.headers, options.admission?.kind, options.inboundWire)) { + const display = createHostedImageDisplayRewrite(); + try { clientJson = display.json(clientJson); } + catch (error) { + if (error instanceof RangeError) return formatErrorResponse(502, "upstream_error", "hosted image result exceeds local display limits"); + throw error; + } finally { display.dispose?.(); } + } + // Effects intentionally run only after both bounded validations. They inspect the same // upstream-facing transcript as the streaming relay and fire once; continuation storage // receives the final client-visible response once after all restorations succeeded. @@ -1086,7 +1122,7 @@ export async function deliverPassthroughResponse( const jsonHeaders = sanitizePassthroughHeaders(headers, codexSafetyBufferingOptions); jsonHeaders.set("content-type", "application/json"); - return markPreinspectedJsonResponse(new Response(JSON.stringify(client.terminal.response), { + return markPreinspectedJsonResponse(new Response(clientJson, { status: upstreamResponse.status, statusText: upstreamResponse.statusText, headers: jsonHeaders, @@ -1365,6 +1401,8 @@ export async function deliverPassthroughResponse( declaredBareWireToolNames, ); } + const requiredFailure = await requiredToolFailure(clientJson); + if (requiredFailure) return requiredFailure; commitReasoningReplayServingRoute(nativeExchange.request.headers); try { rememberPassthroughResponseChecked( diff --git a/structure/transports/responses-combo-boundaries.md b/structure/transports/responses-combo-boundaries.md index 7f58ddbc273..667108fd614 100644 --- a/structure/transports/responses-combo-boundaries.md +++ b/structure/transports/responses-combo-boundaries.md @@ -1,5 +1,29 @@ # Combo Streaming Boundaries +## Buffered required-tool contract + +Before combo success bookkeeping, native delivery in `src/server/responses/passthrough-delivery.ts` +uses `src/server/responses/combo-required-tool-contract.ts` to check successful native +`openai-responses` JSON children with explicit required or named function/custom choices. +Only a parsed `completed` output array can prove a violation. A missing required call becomes +502 `required_tool_contract_unsatisfied`; normal failure handling owns target eligibility, +send budgets, fallback consent and spent-replacement refusal. Usage remains on the failed attempt. +Hosted calls satisfy an any-tool requirement only for a declared mapped capability. A mismatched +hosted call returns HTTP 400 with the same code, marked non-replayable and carrying +`x-should-retry: false`, because it may already have executed. Error formatting retains that client policy. +Provider-executed custom calls use response ownership and `isAuthorizedProviderExecutedCall` in +`src/server/responses-undeclared-tool-guard.ts`; a hosted call cannot masquerade as a client custom call. +Unknown tool declarations abstain; incomplete, failed and opaque payloads retain delivery. +Judgment precedes local hosted-image display projection, which replaces image calls with messages; +canonical SSE buffered for JSON callers is judged after restoration and before that projection too. +Accepted bodies retain response tool ownership and preinspection identity, with stale representation +headers removed after decoding. Existing non-replayable responses bypass inspection. +Translated Chat, runTurn and native Chat terminal owners stay outside this guard; streaming text-only +completion with required tools remains open. No second SSE reader or post-output replay is added. +`src/lib/errors.ts` declares the code and `src/bridge/errors.ts` preserves it through combo error +formatting without overriding cyber-policy precedence. Regression coverage lives in +`tests/server/server-combo-required-tool-contract.test.ts`, including the 151-tool call/result cycle. + ## Combo streaming commit boundary An HTTP 200 does not by itself commit a streaming combo child. The combo parent runs the child's diff --git a/structure/transports/responses-failover.md b/structure/transports/responses-failover.md index 840859268d0..365cb968c8a 100644 --- a/structure/transports/responses-failover.md +++ b/structure/transports/responses-failover.md @@ -1,6 +1,6 @@ # Responses Failover And Replay -Combo `metered` eligibility follows the [runtime consent contract](../runtime.md#request-local-target-compatibility), including recovery selections. In `src/server/responses/run-turn-execution.ts`, streaming and buffered pre-output failures retain structured adapter status/type/code via `adapterFailureFromEvent`, and `formatErrorResponse`'s `classified` option keeps that verdict in the child body instead of re-deriving it from the status, wherever its type and message add nothing to what the status alone says; a verdict read from the message (quota, credentials, cyber policy, context overflow, catalog size) still outranks a generic adapter code, and spend-ledger, cyber-policy and replay-refusal overrides still win, and `consumeComboFailure` re-applies the client allowlist. Spend-ledger storage refusal remains 503. This preserves an exhausted subscription chain’s 429 response, a child's `insufficient_quota`, and the cool-free hop on `unsupported_input_modality` in `src/combos/failover.ts`. Coverage: `tests/adapters/anthropic/anthropic-subscription-chain.test.ts` and `tests/responses/responses-combo-run-turn-classified-failure.test.ts`. `src/server/responses/request-transport.ts` resolves the final Anthropic model ID once for each enabled-pool request and holds its model route through admission. The three account-refusal retry sites in `adapter-dispatch.ts`, `adapter-continuation.ts` and `sidecar-execution.ts` use the same route, keep the original refusal if no replacement exists inside it, and preserve existing send and output replay limits. A local cooldown returns 429 with the earliest known Retry-After among accounts the route can use when they recover, and answers a usage limit when every held account is on spent quota ([quota exhaustion answer](../providers/anthropic-account-pool.md#quota-exhaustion-answer-and-fresh-read-recovery)); explicit fallback includes usable ordinary-pool accounts even when the saved route IDs have been removed, while a strict route stays route-scoped. Selection, refusal and 429-rotation logs use the rule’s 1-based `route:#` position, never its name. +Buffered native required-tool failures follow the [combo contract boundary](responses-combo-boundaries.md#buffered-required-tool-contract): their `required_tool_contract_unsatisfied` code survives `src/bridge/errors.ts` formatting, and the existing failure path owns hopping, usage and non-replayable hosted-effect refusals. Combo `metered` eligibility follows the [runtime consent contract](../runtime.md#request-local-target-compatibility), including recovery selections. In `src/server/responses/run-turn-execution.ts`, streaming and buffered pre-output failures retain structured adapter status/type/code via `adapterFailureFromEvent`, and `formatErrorResponse`'s `classified` option keeps that verdict in the child body instead of re-deriving it from the status, wherever its type and message add nothing to what the status alone says; a verdict read from the message (quota, credentials, cyber policy, context overflow, catalog size) still outranks a generic adapter code, and spend-ledger, cyber-policy and replay-refusal overrides still win, and `consumeComboFailure` re-applies the client allowlist. Spend-ledger storage refusal remains 503. This preserves an exhausted subscription chain’s 429 response, a child's `insufficient_quota`, and the cool-free hop on `unsupported_input_modality` in `src/combos/failover.ts`. Coverage: `tests/adapters/anthropic/anthropic-subscription-chain.test.ts` and `tests/responses/responses-combo-run-turn-classified-failure.test.ts`. `src/server/responses/request-transport.ts` resolves the final Anthropic model ID once for each enabled-pool request and holds its model route through admission. The three account-refusal retry sites in `adapter-dispatch.ts`, `adapter-continuation.ts` and `sidecar-execution.ts` use the same route, keep the original refusal if no replacement exists inside it, and preserve existing send and output replay limits. A local cooldown returns 429 with the earliest known Retry-After among accounts the route can use when they recover, and answers a usage limit when every held account is on spent quota ([quota exhaustion answer](../providers/anthropic-account-pool.md#quota-exhaustion-answer-and-fresh-read-recovery)); explicit fallback includes usable ordinary-pool accounts even when the saved route IDs have been removed, while a strict route stays route-scoped. Selection, refusal and 429-rotation logs use the rule’s 1-based `route:#` position, never its name. Classified 429 admission follows the [Anthropic account-pool contract](../providers/anthropic-account-pool.md#classified-429-admission). Classified account-entitlement 403 recovery and combo 402 credit-refusal cooldowns follow the [Anthropic account-pool contract](../providers/anthropic-account-pool.md#account-entitlement-refusal-recovery); combo error normalization preserves the original physical-send proof, and a stale credential cannot cool its replacement. `src/server/responses/compaction-recovery-policy.ts` is a pure eligibility policy, not a dispatcher. It requires explicit configuration and normalized attempt evidence, preserves ordinary requests, diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 92b58237e10..00a688001bc 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -583,7 +583,7 @@ caller passes `recordBufferedDelivery: false`, which the direct client encoders count their own frames. `src/protocols/encoders/adapter-events.ts` ports the bridge's item state machine for those encoders, so a change to item boundaries, tool naming or terminal handling in `sse.ts` has to be made there too; the parity tests fail when the two diverge. `src/bridge/errors.ts` (`formatErrorResponse`) formats error responses and -keeps only allowlisted transport verdict codes and the explicit local `spend_ledger_storage_unavailable` identity ([storage refusal contract](responses-spend.md#durable-spend-reservations)). Adapter error events take a different path: +keeps allowlisted transport verdict codes, the buffered combo [required-tool verdict and retry policy](responses-combo-boundaries.md#buffered-required-tool-contract), and the explicit local `spend_ledger_storage_unavailable` identity ([storage refusal contract](responses-spend.md#durable-spend-reservations)). Adapter error events take a different path: `src/bridge/internal.ts` preserves explicit verdicts except cyber-policy and known rate-limit mappings ([client retry advice](responses-wire-shapes.md#client-rate-limit-retry-advice)). Explicit incomplete reasons follow the [reason contract](responses-wire-shapes.md#adapter-incomplete-reasons). The shared usage shaping's `input_tokens_details` and `output_tokens_details` are always emitted, diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index a656b15a848..c7858ea3657 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,5 +1,6 @@ { "workbuddy-quota.test.ts": "providers", "workbuddy-login.test.ts": "oauth", "workbuddy-adapter.test.ts": "adapters", "workbuddy-provider.test.ts": "providers", "link-tunnel-history.test.ts": "clients", "link-readiness.test.ts": "clients", "link-diagnostics.test.ts": "clients", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "responses-phase-inference.test.ts": "responses", "tool-identity-hash.test.ts": "adapters", + "server-combo-required-tool-contract.test.ts": "server", "responses-anthropic-compaction-prefix.test.ts": "responses", "service-proxy-env.test.ts": "service", "codex-quota-persistence.test.ts": "codex-integration", diff --git a/tests/helpers/responses-core-source.ts b/tests/helpers/responses-core-source.ts index 2bc9173afe3..930bcfcaa0c 100644 --- a/tests/helpers/responses-core-source.ts +++ b/tests/helpers/responses-core-source.ts @@ -71,6 +71,8 @@ export const RESPONSES_CORE_MODULES = [ "pacing-overload.ts", "reset-replay.ts", "passthrough-delivery.ts", + // Reached from passthrough-delivery.ts: buffered combo children must honor a required tool_choice (#7456). + "combo-required-tool-contract.ts", "buffered-sse-json.ts", "terminal-error-redaction.ts", "non-replayable-error.ts", diff --git a/tests/server/server-combo-required-tool-contract.test.ts b/tests/server/server-combo-required-tool-contract.test.ts new file mode 100644 index 00000000000..968cf18517b --- /dev/null +++ b/tests/server/server-combo-required-tool-contract.test.ts @@ -0,0 +1,683 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ManagementRequest as Request } from "../helpers/management-auth"; +import { comboProviderFactory } from "../helpers/combo-provider"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { clearComboSelectionState, clearComboTargetCooldowns } from "../../src/combos"; +import { clearComboRecallForTests } from "../../src/server/responses/combo-session-recall"; +import { clearKeyCooldowns } from "../../src/providers/key-failover"; +import { clearCodexUpstreamHealth } from "../../src/codex/routing"; +import { addFinalRequestLog, clearRequestLogsForTests, type RequestLogContext } from "../../src/server/request-log"; +import { + clearResponseStateForTests, + flushResponseState, + responseStatePersistPendingForTests, +} from "../../src/responses/state"; +import type { OcxConfig } from "../../src/types"; +import { item as hostedImage } from "../fixtures/hosted-image-display"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; +import { deliverPassthroughResponse } from "../../src/server/responses/passthrough-delivery"; + +/** + * #7456: a Responses combo child that answers a `tool_choice`-required request with a buffered + * `status: "completed"` body carrying zero call items is a false success. The combo used to + * accept any 200 before commit, so the client received `completed` with nothing to execute and + * its agent loop ended silently — the hop machinery was only reachable from a non-OK response. + * + * The scope is exactly the safe half of the issue's acceptance criteria: buffered non-streaming + * successful children, requests that explicitly require a tool, judged before any commit. + * Streaming children keep their conservative preflight rules; the last tests here pin that no + * judgment reaches past them. + */ + +let testDir = ""; +let previousHome: string | undefined; +let isolatedCodexHome: IsolatedCodexHome | null = null; +const servers: Array> = []; +const provider = comboProviderFactory(() => undefined); +let releaseSpendHome: (() => void) | undefined; + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + isolatedCodexHome = installIsolatedCodexHome("ocx-combo-required-tool-codex-"); + testDir = mkdtempSync(join(tmpdir(), "ocx-combo-required-tool-")); + process.env.OPENCODEX_HOME = testDir; + // Direct handler dispatches need the writer lease that startServer normally holds. + releaseSpendHome = acquireOwnedSpendHome(); + clearComboSelectionState(); + clearComboRecallForTests(); + clearComboTargetCooldowns(); + clearKeyCooldowns(); + clearCodexUpstreamHealth(); + clearRequestLogsForTests(); + clearResponseStateForTests(); +}); + +afterEach(async () => { + releaseSpendHome?.(); + releaseSpendHome = undefined; + let responseStatePending = true; + try { + for (const server of servers.splice(0)) await server.stop(true); + await flushResponseState(); + responseStatePending = responseStatePersistPendingForTests(); + } finally { + clearResponseStateForTests(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + isolatedCodexHome?.restore(); + isolatedCodexHome = null; + if (testDir) removeTreeWithRetry(testDir); + clearComboSelectionState(); + clearComboRecallForTests(); + clearComboTargetCooldowns(); + clearKeyCooldowns(); + clearCodexUpstreamHealth(); + clearRequestLogsForTests(); + } + expect(responseStatePending).toBe(false); +}); + +/** Loopback upstream whose lifetime the afterEach owns. */ +function serve(handler: (request: Request) => Response | Promise) { + const server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: handler }); + servers.push(server); + return server; +} + +function baseUrl(server: ReturnType): string { + return `${server.url.toString().replace(/\/$/, "")}/v1`; +} + +function comboConfig(providers: OcxConfig["providers"]): OcxConfig { + return { + port: 0, + defaultProvider: Object.keys(providers)[0]!, + providers, + combos: { + free: { + strategy: "failover", + targets: Object.keys(providers).map((name, index) => ({ provider: name, model: `m${index + 1}` })), + }, + }, + }; +} + +/** A completed buffered Responses body whose output is only a text message. */ +function completedNoCall(model: string, text: string): Record { + return { + id: `resp-${model}`, + object: "response", + status: "completed", + model, + output: [{ + id: "msg_a", + type: "message", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text, annotations: [] }], + }], + usage: { input_tokens: 2, output_tokens: 1, total_tokens: 3 }, + }; +} + +/** A completed buffered Responses body whose only output item is a tool call. */ +function completedWithCall( + model: string, + name: string, + itemType: "function_call" | "custom_tool_call" = "function_call", +): Record { + return { + id: `resp-call-${model}`, + object: "response", + status: "completed", + model, + output: [{ id: `call_${model}`, type: itemType, name, call_id: `call_${model}`, arguments: "{}" }], + usage: { input_tokens: 2, output_tokens: 1, total_tokens: 3 }, + }; +} + +/** A completed buffered Responses body whose only output item is a hosted-tool call. */ +function completedWithHostedCall(model: string, itemType: string): Record { + return { + id: `resp-hosted-${model}`, + object: "response", + status: "completed", + model, + output: [{ id: `hs_${model}`, type: itemType, status: "completed" }], + usage: { input_tokens: 2, output_tokens: 1, total_tokens: 3 }, + }; +} + +const tools = [{ type: "function", name: "shell", parameters: { type: "object" } }]; + +function requiredRequest(extra: Record = {}): Request { + return new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "combo/free", input: "hello", stream: false, tools, ...extra }), + }); +} + +describe("combo required-tool contract on buffered children (#7456)", () => { + test("a folded-SSE contract failure settles reported usage without completion effects", async () => { + let effects = 0; + let settled: unknown; + const context: RequestLogContext = { provider: "fixture", model: "m1", spendTracker: { settle(usage) { settled = usage; } } }; + const request = requiredRequest({ tools: [...tools, { type: "image_generation" }], tool_choice: { type: "function", name: "shell" } }); + const result = await deliverCanonicalBufferedImage(request, hostedImage, () => { effects++; }, context); + expect(result.status).toBe(400); + addFinalRequestLog("folded-required-tool", Date.now(), context, result.status, undefined, () => {}); + expect(settled).toMatchObject({ inputTokens: 7, outputTokens: 2 }); + expect(effects).toBe(0); + }); + test("buffered image display validation precedes completion effects", async () => { + let effects = 0; + const request = requiredRequest({ tools: [{ type: "image_generation" }], tool_choice: "required" }); + request.headers.set("originator", "Codex Desktop"); + const result = await deliverCanonicalBufferedImage(request, { + ...hostedImage, internal_chat_message_metadata_passthrough: { value: "x".repeat(256 * 1024) }, + }, () => { effects++; }); + expect(result.status).toBe(502); + expect(effects).toBe(0); + }); + for (const format of ["json", "sse"]) for (const named of [false, true]) { + test(`local Codex image display preserves required-tool evidence (${format}, named=${named})`, async () => { + let sends = 0; + const upstream = serve(() => { + sends++; + const payload = { id: "resp_image_required", object: "response", status: "completed", model: "m1", output: [hostedImage] }; + return format === "json" ? Response.json(payload) : new Response( + `data: ${JSON.stringify({ type: "response.completed", response: payload })}\n\ndata: [DONE]\n\n`, + { headers: { "content-type": "text/event-stream" } }, + ); + }); + const config = comboConfig({ a: provider("openai-responses", baseUrl(upstream), "key-a") }); + const request = new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json", originator: "Codex Desktop" }, + body: JSON.stringify({ model: "combo/free", input: "synthetic image", stream: false, + tools: [...tools, { type: "image_generation" }], + tool_choice: named ? { type: "function", name: "shell" } : "required" }), + }); + const response = format === "sse" ? await deliverCanonicalBufferedImage(request) : await handleResponses(request, config, { model: "", provider: "" }, { + admission: { kind: "loopback", source: "loopback" }, inboundWire: "responses", + }); + expect(sends).toBe(format === "sse" ? 0 : 1); + expect(response.status).toBe(named ? 400 : 200); + const body = await response.text(); + if (named) { + expect(body).toContain("required_tool_contract_unsatisfied"); + expect(response.headers.get("x-should-retry")).toBe("false"); + } else { + expect(body).toContain("Generated image"); + expect(body).not.toContain("image_generation_call"); + } + }); + } + + test("buffered completed with zero call items hops when tool_choice requires a tool", async () => { + const hits: string[] = []; + const a = serve(() => { + hits.push("a"); + return Response.json(completedNoCall("m1", "false success text")); + }); + const b = serve(() => { + hits.push("b"); + return Response.json(completedWithCall("m2", "shell")); + }); + const parent: RequestLogContext = { model: "", provider: "" }; + const response = await handleResponsesWith(comboConfig({ + a: provider("openai-responses", baseUrl(a), "key-a"), + b: provider("openai-responses", baseUrl(b), "key-b"), + }), requiredRequest({ tool_choice: "required" }), parent); + expect(response.status).toBe(200); + const body = await response.text(); + expect(body).toContain("call_m2"); + expect(body).not.toContain("false success text"); + expect(hits).toEqual(["a", "b"]); + expect(parent).toMatchObject({ + provider: "combo", + model: "combo/free", + resolvedModel: "m2", + attempts: [ + { ordinal: 1, provider: "a", model: "m1", status: 502 }, + { ordinal: 2, provider: "b", model: "m2" }, + ], + }); + }); + + test("a named tool_choice violation hops; a matching call is accepted", async () => { + const hits: string[] = []; + const a = serve(() => { + hits.push("a"); + return Response.json(completedWithCall("m1", "web")); + }); + const b = serve(() => { + hits.push("b"); + return Response.json(completedWithCall("m2", "shell")); + }); + const response = await handleResponsesWith(comboConfig({ + a: provider("openai-responses", baseUrl(a), "key-a"), + b: provider("openai-responses", baseUrl(b), "key-b"), + }), requiredRequest({ + tool_choice: { type: "function", name: "shell" }, + // Declare BOTH tools: with `web` undeclared the existing undeclared-tool guard would be + // what hops, and the named-choice arm of the contract would go untested. + tools: [ + { type: "function", name: "shell", parameters: { type: "object" } }, + { type: "function", name: "web", parameters: { type: "object" } }, + ], + })); + expect(response.status).toBe(200); + expect(await response.text()).toContain("call_m2"); + expect(hits).toEqual(["a", "b"]); + }); + + test("a satisfied required contract is accepted on the first target", async () => { + const hits: string[] = []; + const a = serve(() => { + hits.push("a"); + return Response.json(completedWithCall("m1", "shell")); + }); + const response = await handleResponsesWith(comboConfig({ + a: provider("openai-responses", baseUrl(a), "key-a"), + b: provider("openai-responses", baseUrl(a), "key-b"), + }), requiredRequest({ tool_choice: "required" })); + expect(response.status).toBe(200); + expect(await response.text()).toContain("call_m1"); + expect(hits).toEqual(["a"]); + }); + + test("a custom_tool_call item satisfies a required contract", async () => { + const hits: string[] = []; + const a = serve(() => { + hits.push("a"); + return Response.json(completedWithCall("m1", "apply_patch", "custom_tool_call")); + }); + const response = await handleResponsesWith(comboConfig({ + a: provider("openai-responses", baseUrl(a), "key-a"), + b: provider("openai-responses", baseUrl(a), "key-b"), + }), requiredRequest({ + tool_choice: "required", + // The undeclared-tool guard must not be what answers here: declare the custom tool the + // fixture calls, so only the required-tool contract is under test. + tools: [{ type: "custom", name: "apply_patch" }], + })); + expect(response.status).toBe(200); + expect(await response.text()).toContain("call_m1"); + expect(hits).toEqual(["a"]); + }); + + test("a hosted-tool call satisfies a required contract when the hosted tool is declared", async () => { + const hits: string[] = []; + const a = serve(() => { + hits.push("a"); + return Response.json(completedWithHostedCall("m1", "web_search_call")); + }); + const response = await handleResponsesWith(comboConfig({ + a: provider("openai-responses", baseUrl(a), "key-a"), + b: provider("openai-responses", baseUrl(a), "key-b"), + }), requiredRequest({ + tool_choice: "required", + // The combo forwards hosted-tool declarations to children verbatim, so a child that + // honors `required` with a hosted call is a legitimate success, not a violation. + tools: [{ type: "web_search" }], + })); + expect(response.status).toBe(200); + expect(await response.text()).toContain("web_search_call"); + expect(hits).toEqual(["a"]); + }); + + test("a hosted-tool call cannot satisfy a named function-tool demand", async () => { + const hits: string[] = []; + const a = serve(() => { + hits.push("a"); + return Response.json(completedWithHostedCall("m1", "web_search_call")); + }); + const b = serve(() => { + hits.push("b"); + return Response.json(completedWithCall("m2", "shell")); + }); + const response = await handleResponsesWith(comboConfig({ + a: provider("openai-responses", baseUrl(a), "key-a"), + b: provider("openai-responses", baseUrl(b), "key-b"), + }), requiredRequest({ + tool_choice: { type: "function", name: "shell" }, + tools: [ + { type: "function", name: "shell", parameters: { type: "object" } }, + { type: "web_search" }, + ], + })); + expect(response.status).toBe(400); + expect(await response.text()).toContain("required_tool_contract_unsatisfied"); + expect(hits).toEqual(["a"]); // The hosted call may already have executed. + }); + + test("a hosted call for a hosted tool the request never declared still violates", async () => { + const hits: string[] = []; + const a = serve(() => { + hits.push("a"); + return Response.json(completedWithHostedCall("m1", "web_search_call")); + }); + const b = serve(() => { + hits.push("b"); + return Response.json(completedWithCall("m2", "shell")); + }); + const response = await handleResponsesWith(comboConfig({ + a: provider("openai-responses", baseUrl(a), "key-a"), + b: provider("openai-responses", baseUrl(b), "key-b"), + }), requiredRequest({ + // Function-only catalog: with the default `shell` tools there is no hosted capability + // this child was allowed to execute, so its web_search_call satisfies nothing — were + // hosted items trusted unconditionally, every target could skip its real job this way. + tool_choice: "required", + })); + expect(response.status).toBe(400); + expect(await response.text()).toContain("required_tool_contract_unsatisfied"); + expect(hits).toEqual(["a"]); // Undeclared hosted effects cannot authorize replay either. + }); + + test("a declared hosted capability does not whitelist a different hosted capability", async () => { + const hits: string[] = []; + const a = serve(() => { + hits.push("a"); + return Response.json(completedWithHostedCall("m1", "file_search_call")); + }); + const b = serve(() => { + hits.push("b"); + return Response.json(completedWithCall("m2", "shell")); + }); + const response = await handleResponsesWith(comboConfig({ + a: provider("openai-responses", baseUrl(a), "key-a"), + b: provider("openai-responses", baseUrl(b), "key-b"), + }), requiredRequest({ + tool_choice: "required", + // Declare shell too, so the accepting target's call is not what the undeclared-tool + // guard would answer here — only the hosted-capability mismatch is under test. + tools: [ + { type: "web_search" }, + { type: "function", name: "shell", parameters: { type: "object" } }, + ], + })); + expect(response.status).toBe(400); + expect(await response.text()).toContain("required_tool_contract_unsatisfied"); + expect(hits).toEqual(["a"]); + }); + + test("an alias declaration covers its capability", async () => { + const hits: string[] = []; + const a = serve(() => { + hits.push("a"); + return Response.json(completedWithHostedCall("m1", "web_search_call")); + }); + const response = await handleResponsesWith(comboConfig({ + a: provider("openai-responses", baseUrl(a), "key-a"), + b: provider("openai-responses", baseUrl(a), "key-b"), + }), requiredRequest({ + tool_choice: "required", + // hosted-tool-policy folds web_search_preview onto the same capability as web_search. + tools: [{ type: "web_search_preview" }], + })); + expect(response.status).toBe(200); + expect(await response.text()).toContain("web_search_call"); + expect(hits).toEqual(["a"]); + }); + + test("no tool_choice means the contract is never judged", async () => { + const hits: string[] = []; + const a = serve(() => { + hits.push("a"); + return Response.json(completedNoCall("m1", "ordinary answer")); + }); + const response = await handleResponsesWith(comboConfig({ + a: provider("openai-responses", baseUrl(a), "key-a"), + b: provider("openai-responses", baseUrl(a), "key-b"), + }), requiredRequest()); + expect(response.status).toBe(200); + expect(await response.text()).toContain("ordinary answer"); + expect(hits).toEqual(["a"]); + }); + + test("every target violating returns the typed compatibility error, not a false completed", async () => { + const hits: string[] = []; + const a = serve(() => { + hits.push("a"); + return Response.json(completedNoCall("m1", "first false success")); + }); + const b = serve(() => { + hits.push("b"); + return Response.json(completedNoCall("m2", "second false success")); + }); + const response = await handleResponsesWith(comboConfig({ + a: provider("openai-responses", baseUrl(a), "key-a"), + b: provider("openai-responses", baseUrl(b), "key-b"), + }), requiredRequest({ tool_choice: "required" })); + expect(hits).toEqual(["a", "b"]); + expect(response.status).toBe(502); + const body = await response.text(); + expect(body).toContain("required_tool_contract_unsatisfied"); + expect(body).not.toContain("first false success"); + expect(body).not.toContain("second false success"); + }); + + test("an incomplete buffered child stays delivered even with a required contract", async () => { + const hits: string[] = []; + const a = serve(() => { + hits.push("a"); + return Response.json({ + ...completedNoCall("m1", "ran out of budget"), + status: "incomplete", + incomplete_details: { reason: "max_output_tokens" }, + }); + }); + const response = await handleResponsesWith(comboConfig({ + a: provider("openai-responses", baseUrl(a), "key-a"), + b: provider("openai-responses", baseUrl(a), "key-b"), + }), requiredRequest({ tool_choice: "required" })); + expect(response.status).toBe(200); + expect(await response.text()).toContain("ran out of budget"); + expect(hits).toEqual(["a"]); + }); + + test("a streaming completed stream without call items is never re-judged", async () => { + const hits: string[] = []; + const sse = (events: unknown[]) => new Response( + events.map(event => `data: ${JSON.stringify(event)}\n\n`).join(""), + { headers: { "content-type": "text/event-stream" } }, + ); + const a = serve(() => { + hits.push("a"); + return sse([ + { type: "response.created", response: { id: "r1", status: "in_progress" } }, + { type: "response.output_text.delta", delta: "streamed text" }, + { type: "response.completed", response: { id: "r1", status: "completed", output: [] } }, + ]); + }); + const response = await handleResponsesWith(comboConfig({ + a: provider("openai-responses", baseUrl(a), "key-a"), + b: provider("openai-responses", baseUrl(a), "key-b"), + }), requiredRequest({ stream: true, tool_choice: "required" })); + expect(response.status).toBe(200); + const body = await response.text(); + expect(body).toContain("response.completed"); + expect(body).toContain("streamed text"); + expect(hits).toEqual(["a"]); + }); + + test("151-tool fallback preserves upstream choice, catalog, call ID, usage and result continuation", async () => { + const catalog = Array.from({ length: 151 }, (_, i) => ({ + type: "function", name: `marker_${i}`, parameters: { type: "object", properties: {} }, + })); + const sent: Array<{ target: string; body: Record; authorization: string | null }> = []; + const upstream = (target: string) => serve(async request => { + const body = await request.json() as Record; + sent.push({ target, body, authorization: request.headers.get("authorization") }); + if (body.tool_choice === "auto") { + expect(body.input).toContainEqual({ type: "function_call_output", call_id: "call_m2", output: "synthetic result" }); + return Response.json(completedNoCall(String(body.model), "synthetic result received")); + } + return Response.json(target === "a" ? completedNoCall("m1", "false success") : completedWithCall("m2", "marker_150")); + }); + const a = upstream("a"), b = upstream("b"); + const config = comboConfig({ a: provider("openai-responses", baseUrl(a), "key-a"), b: provider("openai-responses", baseUrl(b), "key-b") }); + const parent: RequestLogContext = { model: "", provider: "" }; + const first = await handleResponsesWith(config, requiredRequest({ tools: catalog, tool_choice: "required", store: false }), parent); + const firstBody = await first.json() as { output: Array> }; + expect(first.status).toBe(200); + expect(firstBody.output).toHaveLength(1); + expect(firstBody.output[0]).toMatchObject({ type: "function_call", name: "marker_150", call_id: "call_m2" }); + expect(sent.map(x => x.target)).toEqual(["a", "b"]); + for (const request of sent) { + expect(request.body.tools).toEqual(catalog); + expect(request.body.tool_choice).toBe("required"); + expect(request.authorization).toBe(`Bearer key-${request.target}`); + } + expect(parent.model).toBe("combo/free"); + expect(parent.resolvedModel).toBe("m2"); + expect(parent.attempts?.[0]?.usage).toMatchObject({ inputTokens: 2, outputTokens: 1 }); + const second = await handleResponsesWith(config, requiredRequest({ + tools: catalog, tool_choice: "auto", store: false, + input: [{ role: "user", content: "Use a marker" }, firstBody.output[0], + { type: "function_call_output", call_id: "call_m2", output: "synthetic result" }], + })); + expect(second.status).toBe(200); + expect(await second.text()).toContain("synthetic result received"); + expect(sent).toHaveLength(3); + expect(sent[2]!.body.tools).toEqual(catalog); + }); + + test("a retrying client cannot repeat a hosted-effect violation", async () => { + let sends = 0; + const a = serve(() => { sends++; return Response.json(completedWithHostedCall("m1", "web_search_call")); }); + const config = comboConfig({ a: provider("openai-responses", baseUrl(a), "key-a") }); + let result!: Response; + // Stainless clients honor x-should-retry before their 408/409/429/5xx status table; + // Codex's configured policy retries 5xx and does not retry ordinary 400s. + for (let attempt = 0; attempt < 2; attempt++) { + result = await handleResponsesWith(config, requiredRequest({ tool_choice: { type: "function", name: "shell" } })); + if (result.headers.get("x-should-retry") === "false" + || !([408, 409, 429].includes(result.status) || result.status >= 500)) break; + await result.text(); + } + expect(sends).toBe(1); + expect(result.status).toBe(400); + expect(result.headers.get("x-should-retry")).toBe("false"); + expect(result.headers.get("retry-after")).toBeNull(); + expect(await result.json()).toMatchObject({ error: { code: "required_tool_contract_unsatisfied" } }); + }); +}); + +const { handleResponses } = await import("../../src/server/responses"); + +// The canonical forward lane buffers SSE for JSON clients; noncanonical SSE stays streaming. +async function deliverCanonicalBufferedImage(request: Request, image: Record = hostedImage, observed = () => {}, logCtx: RequestLogContext = { model: "", provider: "" }): Promise { + const rawBody = await request.json(); + const payload = { id: "resp_image_required", object: "response", status: "completed", model: "m1", output: [image], + usage: { input_tokens: 7, output_tokens: 2, total_tokens: 9 } }; + const upstream = new AbortController(); + return deliverPassthroughResponse( + { req: request, config: { providers: {} }, logCtx, options: { comboAttempt: true, + admission: { kind: "loopback", source: "loopback" }, inboundWire: "responses" } } as Parameters[0], + { authCtx: { kind: "none" } } as Parameters[1], + { parsed: { stream: true, modelId: "m1", _rawBody: rawBody }, clientRequestedStream: false, + translatorBudget: createTestTranslatorBudget(), route: { provider: { adapter: "openai-responses", authMode: "forward", + baseUrl: "https://chatgpt.com/backend-api/codex" }, providerName: "fixture", modelId: "m1", staticPolicy: { model: {} } } } as Parameters[2], + { requestBindings: new Map() } as Parameters[3], {} as Parameters[4], + { plaintextV2AgentMessageToolNames: new Set(), plaintextV2AgentMessageAliasedToolNames: new Set(), + routedMuseToolNameAliases: new Map(), routedNamespaceToolAliases: new Map(), + commitReasoningReplayServingRoute() {}, recordTerminalOutcomes: false } as Parameters[5], + { upstreamResponse: new Response(`data: ${JSON.stringify({ type: "response.completed", response: payload })}\n\ndata: [DONE]\n\n`, + { headers: { "content-type": "text/event-stream" } }), upstream, request, connectMs: 0, + imageGenCallAliases: new Map(), selfNamedNamespaceScrubAuthorization: new Set(), authorizedBareNamespaceToolAliases: new Map(), + routedCustomToolNames: new Set(), routedCustomToolRepairNames: new Set(), declaredWireToolNames: new Set(["shell"]), + routedToolSearchNames: new Set(), functionRepairSchemas: new Map(), declaredNamelessClientCallTypes: new Set(), + providerExecutedCallTypes: new Set(), declaredBareWireToolNames: new Set(), recoverableBareCustomWireToolNames: new Set(), + undeclaredToolGuardActive: true, rememberPassthroughResponse: false, rememberPassthroughResponseChecked() {}, noteInspectedPayload: observed, + normalizeFunctionCompletionJson: (value: string) => value, outboundRequestBody: "{}" } as Parameters[6], + ); +} +const { guardBufferedComboRequiredTool, judgeBufferedRequiredTool, requiredToolContractOf } = await import("../../src/server/responses/combo-required-tool-contract"); +const { attachResponseToolOwnership, responseToolOwnership } = await import("../../src/server/responses/response-tool-ownership"); +const { isNonReplayableResponse, markResponseNonReplayable } = await import("../../src/lib/upstream-retry"); +const { formatErrorResponse } = await import("../../src/bridge/errors"); + +describe("buffered required-tool safety controls", () => { + for (const choice of ["required", { type: "function", name: "shell" }]) { + test(`provider-owned custom calls cannot be replayed as client calls: ${JSON.stringify(choice)}`, async () => { + const original = attachResponseToolOwnership(Response.json({ status: "completed", output: [{ + type: "custom_tool_call", name: "x_keyword_search", call_id: "xs_call-1", input: "synthetic search", + }] }), new Set([{ itemType: "custom_tool_call", callIdPrefix: "xs_call-" }])); + const result = await guardBufferedComboRequiredTool(original, { tools, tool_choice: choice }, "openai-responses"); + expect(result.status).toBe(400); + expect(result.headers.get("x-should-retry")).toBe("false"); + expect(isNonReplayableResponse(result)).toBe(true); + expect(await result.json()).toMatchObject({ error: { code: "required_tool_contract_unsatisfied" } }); + }); + } + test("error formatter retains the contract code without overriding cyber-policy precedence", async () => { + const response = formatErrorResponse(502, "upstream_error", "missing required call", { code: "required_tool_contract_unsatisfied" }); + expect(await response.json()).toMatchObject({ error: { code: "required_tool_contract_unsatisfied" } }); + const cyber = formatErrorResponse(400, "cyber_policy", "cyber policy refusal", { code: "required_tool_contract_unsatisfied" }); + expect(await cyber.json()).toMatchObject({ error: { code: "cyber_policy" } }); + }); + test("accepted rebuild preserves ownership and removes stale representation headers", async () => { + const payload = completedWithCall("m1", "shell"); + const original = attachResponseToolOwnership(new Response(JSON.stringify(payload), { + headers: { "content-type": "application/json", "content-encoding": "gzip", "content-length": "999" }, + }), new Set([{ itemType: "custom_tool_call", callIdPrefix: "xs_call-" }])); + const accepted = await judgeBufferedRequiredTool(original, { kind: "named", name: "shell" }); + expect(accepted.headers.get("content-encoding")).toBeNull(); + expect(accepted.headers.get("content-length")).toBeNull(); + expect(responseToolOwnership(accepted)).toEqual(new Set([{ itemType: "custom_tool_call", callIdPrefix: "xs_call-" }])); + expect(await accepted.json()).toEqual(payload); + }); + + for (const text of ["not JSON", "null", '{"status":"completed"}', '{"status":"failed","output":[]}', '{"status":"incomplete","output":[]}']) { + test(`opaque or non-completed payload is not a contract verdict: ${text}`, async () => { + const result = await judgeBufferedRequiredTool(new Response(text), { kind: "named", name: "shell" }); + expect(result.status).toBe(200); + expect(await result.text()).toBe(text); + }); + } + + for (const choice of [undefined, "auto", "none", null, {}]) { + test(`no mandatory choice is inferred from ${JSON.stringify(choice)}`, () => { + expect(requiredToolContractOf({ tools, tool_choice: choice })).toBeUndefined(); + }); + } + test("unrecognized hosted output contracts abstain", () => { + for (const type of ["tool_search", "local_shell", "x_search", "future_tool", "namespace"]) { + expect(requiredToolContractOf({ tools: [{ type }], tool_choice: "required" })).toBeUndefined(); + } + expect(requiredToolContractOf({ tools: [], tool_choice: "required" })).toBeUndefined(); + }); + + test("a non-replayable success is neither consumed nor converted into a hop", async () => { + const original = Response.json(completedNoCall("m1", "already executed")); + markResponseNonReplayable(original); + const result = await guardBufferedComboRequiredTool(original, { tools, tool_choice: "required" }, "openai-responses"); + expect(result).toBe(original); + expect(result.bodyUsed).toBe(false); + expect(isNonReplayableResponse(result)).toBe(true); + }); + for (const adapter of ["openai-chat", "test-run-turn", undefined]) { + test(`adapter terminal ownership stays with ${adapter}`, async () => { + const original = Response.json(completedNoCall("m1", "adapter-owned")); + expect(await guardBufferedComboRequiredTool(original, { tools, tool_choice: "required" }, adapter)).toBe(original); + expect(original.bodyUsed).toBe(false); + }); + } + test("body-read failures propagate without inventing required-tool evidence", async () => { + const failure = new Error("synthetic body failure"); + const original = new Response(new ReadableStream({ start(controller) { controller.error(failure); } })); + expect(judgeBufferedRequiredTool(original, { kind: "named", name: "shell" })).rejects.toBe(failure); + }); +}); + +async function handleResponsesWith(config: OcxConfig, request: Request, parent?: RequestLogContext) { + return handleResponses(request, config, parent ?? { model: "", provider: "" }); +} From 81ce59591c06b2f1bde71748588fda69078662a2 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 27 Oct 2026 00:15:07 +0900 Subject: [PATCH 091/101] fix(update): keep Codex routing for the restarted proxy during an update (#7747) A dashboard update with restart ran a full ocx stop, which restored native Codex and the pristine catalog. A Codex app-server that loaded the catalog before the new proxy's first sync treated routed models as unknown, fell back to generic metadata and could loop on compaction. When the update job will restart the proxy, the update's stop now keeps an exact handover receipt (exit 81) once the stopped proxy confirmed the deferral or a clean service-manager stop took it down; the old proxy exits as a recycle; both updater lanes proceed past 81 under the existing gates; the restarted proxy adopts the handed-over journal during startup recovery and discharges the receipt after its first published sync. An ordinary stop, an update without restart, and a startup with Codex integration off behave as before. Closes #7423 --- .../src/content/docs/guides/web-dashboard.md | 2 +- .../content/docs/reference/cli/lifecycle.md | 7 +- scripts/test-layout/layout.json | 2 +- src/cli/cross-home-owner.ts | 14 +- src/cli/index.ts | 27 +- src/codex/desired-state.ts | 7 +- src/codex/journal.ts | 15 + src/config/pending-teardown.ts | 23 +- src/server/stop-teardown.ts | 20 + src/update/bind-probe.ts | 29 ++ src/update/job.ts | 39 +- src/update/stop-contract.d.mts | 1 + src/update/stop-contract.mjs | 14 + src/update/stop-decision.d.mts | 2 +- src/update/stop-decision.mjs | 16 +- src/update/teardown-handover.ts | 166 ++++++++ structure/runtime.md | 4 +- tests/cli/cli-dispatch.test.ts | 3 +- tests/fixtures/handover-journal-probe.ts | 50 +++ tests/fixtures/parent-stop-runner.ts | 11 +- tests/fixtures/test-layout-expected.json | 1 + tests/providers/xai/grok-lifecycle.test.ts | 9 +- tests/update/update-job.test.ts | 2 +- .../update/update-stop-classification.test.ts | 3 +- tests/update/update-teardown-handover.test.ts | 399 ++++++++++++++++++ 25 files changed, 809 insertions(+), 57 deletions(-) create mode 100644 src/update/bind-probe.ts create mode 100644 src/update/teardown-handover.ts create mode 100644 tests/fixtures/handover-journal-probe.ts create mode 100644 tests/update/update-teardown-handover.test.ts diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 6cdb10f4d2f..0792854aa24 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -114,7 +114,7 @@ is visible) and never forces an upstream refresh. | **Native reasoning retention** | Independently opt into reasoning ciphertext forwarding on native model or account changes. Both default off; account retention is experimental. See [the configuration scope](/reference/configuration/server/#native-reasoning-retention). | | **Sub-agent delegation** | Choose a native or routed model and optional reasoning effort shared by OpenCodex delegation guidance and the separate native-default opt-in. This is not a proxy-side per-spawn router; see below. | | **Sidecars** | Choose the web-search model and effort plus the vision-description model. Changes apply on the next request. | -| **Maintenance** | Resync the Codex model catalog, inspect project-local config bypass warnings, check the latest or preview release, and run an update with optional proxy restart. In the desktop shell, its update entry opens the native app update page instead of running the package updater. | +| **Maintenance** | Resync the Codex model catalog, inspect project-local config bypass warnings, check the latest or preview release, and run an update with optional proxy restart. With restart on, Codex routing and its model catalog stay in place while the package is replaced, so a Codex app that loads them during the update still sees your routed models; the restarted proxy takes them over, and `ocx stop` restores native Codex if it never comes back. In the desktop shell, its update entry opens the native app update page instead of running the package updater. | | **Startup safety** | Show whether injected Codex routing survives a restart, with separate service and launcher-shim health plus exact repair commands. | | **Windows tray** | Install a per-user login tray for one-click proxy start, stop, restart, dashboard access, and status. The tray is a controller, not a proxy restart service. | | **Codex autostart** | Allow an already-installed Codex launcher shim to run `ocx ensure`. This toggle does not install a shim or background service. | diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 9f80c44487b..cabb3012544 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -124,8 +124,11 @@ failure to restore shared client configuration keeps the stop failed and its rec carries the outcome class (`stopped`, `not-running`, `history-incomplete`, `history-deferred`, or `failed`), the service and proxy classifications, whether the runtime is down, and a stable one-line message. Exit codes are identical with and without `--json`: 0 on success, 1 on failure, -79 when only Codex history cleanup did not complete, and 80 when the shared teardown was deferred -and is still owed. +79 when only Codex history cleanup did not complete, 80 when the shared teardown was deferred +and is still owed, and 81 when a dashboard update that will restart the proxy handed Codex routing +and its catalog to the restarted proxy instead of restoring native Codex. With 81 the proxy is +down, and the restarted proxy releases the kept teardown after its first sync; if it never starts, +the next `ocx stop` restores native Codex. For a desktop-approved takeover, `ocx stop --json` also accepts the complete set of `--expect-*` values supplied by `ocx resolve --json`. On Windows, this guarded stop can diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index bc228afaffb..8d1790a74bf 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1848,7 +1848,7 @@ "update-refresh.test.ts": "update", "update-restart-lease.test.ts": "update", "update-stop-classification.test.ts": "update", - "update-stop-first.test.ts": "update", + "update-stop-first.test.ts": "update", "update-teardown-handover.test.ts": "update", "update-transactional-leftovers.test.ts": "update", "update-transactional.test.ts": "update", "update-tray-handoff.test.ts": "update", diff --git a/src/cli/cross-home-owner.ts b/src/cli/cross-home-owner.ts index a296d7698cb..1d70a35fdf7 100644 --- a/src/cli/cross-home-owner.ts +++ b/src/cli/cross-home-owner.ts @@ -9,6 +9,9 @@ import { detectCodexRoutingDrift } from "../codex/routing-drift"; import { readBoundedCodexConfig } from "../codex/inject/bounded-config-reader"; import { currentExternalCodexModelProvider } from "../codex/inject"; import { reconcileJournal } from "../codex/journal"; +import { shouldSyncCodexOnStart } from "../codex/desired-state"; +import { loadConfig } from "../config"; +import { updateTeardownHandoverPending } from "../update/teardown-handover"; import { markSiblingStart, siblingOfLivePort } from "../codex/sibling-start"; import { readClientConnectionState } from "../client/state"; import { findManagedRegion, resolveGrokHome } from "../grok/inject"; @@ -350,5 +353,14 @@ export function reconcileStartupJournal(): void { const clientState = readClientConnectionState(); reconcileJournal(clientState.kind === "connected" ? { activeClientApiKeyId: clientState.value.apiKeyId } - : undefined); + : { adoptUpdateHandover: updateTeardownHandoverPending() && startupSyncEnabled() }); +} + +/** + * #7423: adoption keeps routing only for a startup that will re-publish it. A user who has + * since turned the Codex integration off gets the ordinary recovery, even with a stale + * handover receipt on disk; an unreadable config is treated the same way. + */ +function startupSyncEnabled(): boolean { + try { return shouldSyncCodexOnStart(loadConfig()); } catch { return false; } } diff --git a/src/cli/index.ts b/src/cli/index.ts index 9b59169f38d..5fdbac8685d 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -42,7 +42,8 @@ import { type RetainedCodexProviderTable, } from "../codex/inject/restore"; import { stripGrokConfig } from "../grok/inject"; -import { STOP_HISTORY_DEFERRED_EXIT_CODE, STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "../update/stop-contract.mjs"; +import { STOP_HISTORY_DEFERRED_EXIT_CODE, STOP_HISTORY_INCOMPLETE_EXIT_CODE, STOP_TEARDOWN_HANDED_OVER_EXIT_CODE } from "../update/stop-contract.mjs"; +import { announceStopHandover, completeUpdateTeardownHandover, forgetUpdateTeardownHandover, stopHandsTeardownOver, updateTeardownHandoverRequested } from "../update/teardown-handover"; import { describeHistoryJobFailure, resolveCodexHistoryJobTarget, @@ -143,7 +144,7 @@ import { StartOwnershipRollbackUncertainError, } from "./start-ownership-publication"; import { syncModelsToCodex } from "../codex/sync"; -import { localClientSkipReason, shouldSyncGrokOnStart, syncCodexOnStartIfEnabled } from "../codex/desired-state"; +import { localClientSkipReason, shouldSyncCodexOnStart, shouldSyncGrokOnStart, syncCodexOnStartIfEnabled } from "../codex/desired-state"; import { honorSiblingMarker, markSiblingStart, siblingOfLivePort, siblingRuntimeField, siblingStopFoundOwner, withoutSiblingMarker } from "../codex/sibling-start"; import { consumeSiblingHandoff } from "../codex/sibling-handoff"; import { @@ -384,6 +385,7 @@ async function findProxyOwnerBeforeJournalRecovery( } async function handleStart(options: { block?: boolean } = {}) { + forgetUpdateTeardownHandover(); // #7423: a recovery start must not pass the marker to its update workers updateRestartChild?.check(); // A supervised service child defers to a foreign recorded owner before doing // anything else. 'ocx service start' refuses this activation path already, but @@ -752,6 +754,8 @@ async function handleStart(options: { block?: boolean } = {}) { }, ); if (!startupSync.ran) console.log(startupLeftCodexNativeLine(localClientSkipReason(config), server.port ?? port)); + // #7423: routing an update kept in place for this proxy is ours once the startup sync published. + if (!siblingStart && startupSync.published) completeUpdateTeardownHandover(pid => isProcessAlive(pid) && isLikelyOcxProcess(pid)); await refreshOwnedRaycastCatalog(config, port); if (!cleaned && !shuttingDown) healthSentinel = startServiceHealthSentinel(config); // after startup reconciliation; never mid-boot or mid-drain // #1046: one warning per startup, after BOTH writes. The server's cache @@ -1130,11 +1134,14 @@ async function handleStopUnlocked(snapshot?: GuardedStopSnapshot) { const inheritedTeardowns = listPendingTeardowns() .filter(read => isPendingTeardownAbandoned(read, teardownOwnerStillRunning)); let teardownNonce: string | undefined; + // #7423: an update that restarts the proxy asks this stop to leave Codex routing for the replacement. + const handoverRequested = updateTeardownHandoverRequested(process.env, () => shouldSyncCodexOnStart(loadConfig())) && !stoppingSibling && !connectedClientSkip; + let deferralHonoredByProxy = false; const claimTeardown = (endpoint: { hostname: string; port: number }, endpointSource: "exact" | "guessed") => { if (teardownNonce || stoppingSibling) return; if (connectedClientSkip) return; try { - teardownNonce = claimPendingTeardown(endpoint, endpointSource).nonce; + teardownNonce = claimPendingTeardown(endpoint, endpointSource, process.pid, handoverRequested ? "update-restart" : undefined).nonce; } catch (err) { // Without a receipt the proxy performs its own teardown, which is the pre-#3008 // behaviour: correct for every backend that cannot respawn, and merely early for @@ -1161,12 +1168,15 @@ async function handleStopUnlocked(snapshot?: GuardedStopSnapshot) { // A valid receipt means the proxy deferred shared teardown to this process. If the // receipt could not be written, the proxy restores it itself and the caller must not // attempt a second restore after a graceful stop. A hard-kill always leaves restore - // to this process. + // to this process. #7423: only a confirmed deferral kept the proxy's own exit cleanup off. + if (graceful && teardownNonce) deferralHonoredByProxy = true; return graceful && !teardownNonce; }; const approvedEndpoint = snapshot ? { hostname: probeHostname(snapshot.approval.hostname || undefined), port: snapshot.approval.port } : null; + // Only an exact runtime endpoint may back a handover; without one the ordinary restore runs. + const claimHandoverBeforeManager = () => { const exact = handoverRequested ? endpointOf(readRuntimePort()) : null; if (exact) claimTeardown(exact, "exact"); }; let guardedStep: Awaited> | null = null; try { const serviceStop = snapshot @@ -1182,7 +1192,8 @@ async function handleStopUnlocked(snapshot?: GuardedStopSnapshot) { })).service // A sibling never runs under a service manager: an installed one belongs to the live owner, // and its ownership check would fail this stop and skip the stale-record purge below. - : stoppingSibling || connectedClientAlreadyStopped ? "absent" : stopServiceIfInstalledDetailed(); + // #7423: a manager stop takes the proxy down itself, so a handover claims its receipt first. + : stoppingSibling || connectedClientAlreadyStopped ? "absent" : (claimHandoverBeforeManager(), stopServiceIfInstalledDetailed()); record.service = serviceStop; stoppedService = serviceStop === "stopped" || serviceStop === "stopped-respawnable"; schedulerCanRespawn = serviceStop === "stopped-respawnable"; @@ -1425,7 +1436,10 @@ async function handleStopUnlocked(snapshot?: GuardedStopSnapshot) { if (stoppingSibling) console.log(`↩️ Client routing stays on the proxy at port ${siblingOfPort}; this sibling had no shared teardown to run.`); if (connectedClientSkip) console.log(connectedClientSkip); const restoreBlocked = ownershipBlocked || inheritedBlocks || nativeRestoreHandledByProxy || stoppingSibling || connectedClientSkip !== null; - if (!restoreBlocked) { + const handedOver = stopHandsTeardownOver({ requested: handoverRequested, restoreBlocked, nonce: teardownNonce, + stopFailed, recoveredCount: recoveredNonces.length, proxy: record.proxy, service: record.service, deferralHonored: deferralHonoredByProxy }); + if (handedOver) announceStopHandover(); + else if (!restoreBlocked) { if (recoveredNonces.length > 0) { // A previous deferred stop died before restoring, and the probe says its endpoint is // not answering. That is the whole point of leaving the receipt behind. @@ -1503,6 +1517,7 @@ async function handleStopUnlocked(snapshot?: GuardedStopSnapshot) { ? STOP_HISTORY_DEFERRED_EXIT_CODE : 1; } + else if (handedOver) process.exitCode = STOP_TEARDOWN_HANDED_OVER_EXIT_CODE; const signals = { failed: stopFailed, historyOnly: historyOnlyFailure, diff --git a/src/codex/desired-state.ts b/src/codex/desired-state.ts index c9b29d083c8..f9fee1abd77 100644 --- a/src/codex/desired-state.ts +++ b/src/codex/desired-state.ts @@ -258,7 +258,7 @@ export async function syncCodexOnStartIfEnabled( config: LocalClientSyncConfig, sync: CodexStartupSync = defaultStartupSync, readinessGate?: ReadinessGate, -): Promise<{ ran: boolean; catalogWritten: boolean; cacheSynced: boolean }> { +): Promise<{ ran: boolean; catalogWritten: boolean; cacheSynced: boolean; published?: boolean }> { if (!shouldSyncCodexOnStart(config)) { // The user explicitly turned Codex off: there is nothing to sync, so the // proxy is ready as soon as it is up. The gate is driven here so /readyz @@ -277,10 +277,15 @@ export async function syncCodexOnStartIfEnabled( const outcome = readinessGate ? await runStartupReadinessSync(readinessGate, async () => (await sync(port)) ?? null) : await sync(port).catch(() => undefined); + // #7423: "published" means this proxy now owns what Codex reads: the sync succeeded and + // wrote config and catalog (or the catalog, when an external provider owns config.toml). + // A skipped sync (sibling, hub gate, desired OFF) published nothing of its own. + const status = (outcome as { status?: unknown } | null | undefined)?.status; return { ran: true, catalogWritten: outcome?.catalogWritten === true, cacheSynced: outcome?.cacheSynced === true, + published: outcome?.ok === true && (status === "applied" || status === "catalog-only"), }; } diff --git a/src/codex/journal.ts b/src/codex/journal.ts index 72a4aebe3ca..fb8311a551e 100644 --- a/src/codex/journal.ts +++ b/src/codex/journal.ts @@ -486,6 +486,12 @@ function warnIfJournalRetained(): void { export interface ReconcileJournalOptions { activeClientApiKeyId?: string; + /** + * #7423: an update stopped the previous proxy and handed its Codex routing to this one. + * A process-owned journal whose owner is gone is then adopted rather than replayed, so + * startup does not restore native config in the seconds before its own sync re-injects. + */ + adoptUpdateHandover?: boolean; } export function reconcileJournal(options: ReconcileJournalOptions = {}): boolean { @@ -527,6 +533,15 @@ export function reconcileJournal(options: ReconcileJournalOptions = {}): boolean return false; } } + if (options.adoptUpdateHandover) { + // The original baseline and injected-state hashes stay as written; only ownership moves. + // Published like every other journal write, so a replaced Codex home is refused rather + // than overwritten; a refusal preserves both the journal and the config. + const adopted = JSON.stringify({ ...journal, pid: process.pid, owner: { kind: "process", pid: process.pid } }); + try { publishCodexArtifact(JOURNAL_PATH, handle, (path, hooks) => atomicWriteFile(path, adopted, undefined, hooks)); } + catch { console.error("⚠️ The Codex journal handed over by an update could not be adopted; the journal and configuration were preserved."); } + return false; + } const restored = restoreJournalState({ heldConfigWriteLock: handle }); if (restored.unverified) { console.error("⚠️ Codex journal recovery was not verified; current configuration files and the journal were preserved."); diff --git a/src/config/pending-teardown.ts b/src/config/pending-teardown.ts index 02228d5b748..5632d0739e5 100644 --- a/src/config/pending-teardown.ts +++ b/src/config/pending-teardown.ts @@ -54,6 +54,15 @@ export type PendingTeardownReceipt = { * guessed receipt therefore fails closed into manual recovery. */ endpointSource: "exact" | "guessed"; + /** + * Set when the stop handed its teardown to a replacement proxy instead of performing it + * (#7423). An update that is about to restart the proxy keeps Codex routing and the + * routed catalog in place across the gap, so a Codex process that loads them while the + * package is replaced still sees the routed rows. The obligation is the same one: the + * replacement discharges it after its first successful publication, and if no + * replacement ever comes up, the next `ocx stop` finishes it like any abandoned receipt. + */ + handover?: "update-restart"; }; /** @@ -111,6 +120,7 @@ function isReceipt(value: unknown, nonce: string): value is PendingTeardownRecei && receipt.nonce === nonce && typeof receipt.createdAt === "string" && (receipt.endpointSource === "exact" || receipt.endpointSource === "guessed") + && (receipt.handover === undefined || receipt.handover === "update-restart") && endpointOk; } @@ -119,11 +129,15 @@ export function claimPendingTeardown( endpoint: { hostname: string; port: number }, endpointSource: "exact" | "guessed", ownerPid: number = process.pid, + handover?: "update-restart", ): PendingTeardownReceipt { const dir = getConfigDir(); assertNotRealHomeUnderTest(dir); const nonce = randomBytes(16).toString("hex"); - const receipt: PendingTeardownReceipt = { ownerPid, nonce, createdAt: new Date().toISOString(), endpoint, endpointSource }; + const receipt: PendingTeardownReceipt = { + ownerPid, nonce, createdAt: new Date().toISOString(), endpoint, endpointSource, + ...(handover ? { handover } : {}), + }; atomicWriteFile(pendingTeardownPathFor(nonce), JSON.stringify(receipt, null, 2) + "\n"); return receipt; } @@ -323,3 +337,10 @@ export function deferralMatchesReceipt(nonce: string | null): boolean { if (!nonce || !NONCE_RE.test(nonce)) return false; return readPendingTeardown(nonce).state === "valid"; } + +/** Does this request name a valid obligation that an update handed to its replacement (#7423)? */ +export function isUpdateHandoverReceipt(nonce: string | null): boolean { + if (!nonce || !NONCE_RE.test(nonce)) return false; + const read = readPendingTeardown(nonce); + return read.state === "valid" && read.receipt.handover === "update-restart"; +} diff --git a/src/server/stop-teardown.ts b/src/server/stop-teardown.ts index d31e2b5dd19..515743e73a0 100644 --- a/src/server/stop-teardown.ts +++ b/src/server/stop-teardown.ts @@ -23,6 +23,10 @@ export function stopCapabilities() { export type StopTeardownIo = { /** Does the nonce this request carries name a readable obligation on disk? */ ownsReceipt?: (nonce: string | null) => boolean; + /** Does that obligation hand the teardown to the proxy an update restarts (#7423)? */ + isHandoverReceipt?: (nonce: string | null) => boolean; + /** Keep Codex, Grok and env fences in place when this process exits. */ + keepClientConfigOnExit?: () => void; restoreNativeCodex?: () => Promise; stripGrok?: () => GrokStripResult; }; @@ -96,6 +100,22 @@ export async function performStopTeardown(url: URL, io: StopTeardownIo = {}): Pr } const ownsReceipt = io.ownsReceipt ?? deferralMatchesReceipt; if (deferralHonored(url, ownsReceipt)) { + // A handover outlives this process: the exit cleanup would otherwise restore native + // Codex and put the pristine catalog back right after the stop kept them (#7423). + // Exiting as a recycle leaves them for the replacement, as a dashboard restart does. + const nonce = url.searchParams.get("teardownNonce"); + const handover = io.isHandoverReceipt + ? io.isHandoverReceipt(nonce) + : (await import("../config/pending-teardown")).isUpdateHandoverReceipt(nonce); + if (handover) { + if (io.keepClientConfigOnExit) io.keepClientConfigOnExit(); + else (await import("./lifecycle")).markRecyclingForExit(); + return { + success: true, + message: "Proxy stopping; Codex routing and catalog kept for the updated proxy.", + sharedTeardown: "deferred", + }; + } // Not "native Codex restored": nothing was restored here, and claiming otherwise // would be a success message the operator cannot verify. return { diff --git a/src/update/bind-probe.ts b/src/update/bind-probe.ts new file mode 100644 index 00000000000..016402abb9f --- /dev/null +++ b/src/update/bind-probe.ts @@ -0,0 +1,29 @@ +import { spawnSync } from "node:child_process"; + +/** + * Strict bind script: exit 0 only after listen+close. Any listen error (including + * Windows ghost-TCB failures under Bun) is busy — matches published `ocx start` + * probes that treat every listen error as unavailable. + */ +export function strictBindProbeScript(port: number, hostname: string): string { + return [ + "const net=require('net');", + "const s=net.createServer();", + "s.once('error',()=>process.exit(2));", + `s.listen(${Math.trunc(port)},${JSON.stringify(hostname)},()=>s.close(()=>process.exit(0)));`, + "setTimeout(()=>process.exit(3),2500);", + ].join(""); +} + +export function spawnBindProbe(bin: string, script: string): boolean { + try { + const r = spawnSync(bin, ["-e", script], { + windowsHide: true, + timeout: 4000, + stdio: "ignore", + }); + return r.status === 0; + } catch { + return false; + } +} diff --git a/src/update/job.ts b/src/update/job.ts index c9773fb8b92..890a9ef46da 100644 --- a/src/update/job.ts +++ b/src/update/job.ts @@ -53,6 +53,8 @@ import { } from "./npm-cache-preflight.mjs"; import { guiUpdateWorkerCommand } from "./worker-launch"; import { withoutSiblingMarker } from "../codex/sibling-start"; +import { spawnBindProbe, strictBindProbeScript } from "./bind-probe"; +import { withoutUpdateTeardownHandover, withUpdateTeardownHandover } from "./teardown-handover"; import type { WorkerLaunchContext } from "./worker-launch"; const RELEASE_NOTES_URL = "https://github.com/lidge-jun/opencodex/releases/latest"; @@ -140,34 +142,6 @@ function usesNodeLauncher(installer: Installer): boolean { return installer === "npm" || installer === "pnpm"; } -/** - * Strict bind script: exit 0 only after listen+close. Any listen error (including - * Windows ghost-TCB failures under Bun) is busy — matches published `ocx start` - * probes that treat every listen error as unavailable. - */ -function strictBindProbeScript(port: number, hostname: string): string { - return [ - "const net=require('net');", - "const s=net.createServer();", - "s.once('error',()=>process.exit(2));", - `s.listen(${Math.trunc(port)},${JSON.stringify(hostname)},()=>s.close(()=>process.exit(0)));`, - "setTimeout(()=>process.exit(3),2500);", - ].join(""); -} - -function spawnBindProbe(bin: string, script: string): boolean { - try { - const r = spawnSync(bin, ["-e", script], { - windowsHide: true, - timeout: 4000, - stdio: "ignore", - }); - return r.status === 0; - } catch { - return false; - } -} - /** * Live global package Bun — not the npm rename tree the update worker may still * be executing from (`@bitkyc08/.opencodex-*`). Reject the tiny postinstall @@ -700,12 +674,14 @@ function runLoggedCommand( bin: string, args: string[], timeout: number, + env?: NodeJS.ProcessEnv, ): { status: number | null; signal: NodeJS.Signals | null; timedOut: boolean } { job = updateJob(job, {}, `$ ${formatCommand(bin, args)}`); const result = spawnSync(bin, args, { encoding: "utf8", timeout, windowsHide: true, + ...(env ? { env } : {}), }); const stdout = typeof result.stdout === "string" ? result.stdout.trim() : ""; const stderr = typeof result.stderr === "string" ? result.stderr.trim() : ""; @@ -1812,6 +1788,7 @@ export interface GuiUpdateWorkerIo { bin: string, args: string[], timeout: number, + env?: NodeJS.ProcessEnv, ) => { status: number | null; signal: NodeJS.Signals | null }; } @@ -1933,7 +1910,11 @@ export async function runGuiUpdateWorker( - 대안 분석: (1) 서버에서 runUpdate 직접 호출: process.exit/stdio/실행 파일 교체 위험. (2) GUI에서 CLI 명령 안내만 제공: 자동 업데이트 UX 부족. (3) 숨은 worker가 Node launcher/Bun 전역 명령을 실행: 상태 추적과 안전한 재시작이 가능. - 선택 근거: 현재 CLI의 package-manager self-update 우회를 재사용하면서도 GUI 서버 요청 생명주기와 설치 작업을 분리할 수 있어 가장 안정적이다. */ - const result = (io.runCommandFn ?? runLoggedCommand)(job, cmd.bin, cmd.args, UPDATE_TIMEOUT_MS); + // #7423: this job restarts the proxy itself, so the update's stop hands Codex routing + // and the routed catalog to the replacement instead of restoring native Codex for the + // seconds in between. Without a restart the stop restores, even under an inherited marker. + const updateEnv = restart ? withUpdateTeardownHandover(process.env) : withoutUpdateTeardownHandover(process.env); + const result = (io.runCommandFn ?? runLoggedCommand)(job, cmd.bin, cmd.args, UPDATE_TIMEOUT_MS, updateEnv); if (result.status !== 0) { if (check.installer === "pnpm") { const verifiedLauncher = pnpmOwner diff --git a/src/update/stop-contract.d.mts b/src/update/stop-contract.d.mts index 1efb77c42d7..f0740172b23 100644 --- a/src/update/stop-contract.d.mts +++ b/src/update/stop-contract.d.mts @@ -1,3 +1,4 @@ /** Declaration for the plain-ESM stop contract shared with `bin/ocx.mjs`. */ export declare const STOP_HISTORY_INCOMPLETE_EXIT_CODE: 79; export declare const STOP_HISTORY_DEFERRED_EXIT_CODE: 80; +export declare const STOP_TEARDOWN_HANDED_OVER_EXIT_CODE: 81; diff --git a/src/update/stop-contract.mjs b/src/update/stop-contract.mjs index b86018ea0d7..bf7e38da08f 100644 --- a/src/update/stop-contract.mjs +++ b/src/update/stop-contract.mjs @@ -32,3 +32,17 @@ export const STOP_HISTORY_INCOMPLETE_EXIT_CODE = 79; * dispatcher already emit. */ export const STOP_HISTORY_DEFERRED_EXIT_CODE = 80; + +/** + * The exit code `ocx stop` uses to say "the proxy is down and its shared teardown was + * handed to the proxy this update is about to start" (#7423). + * + * Only an update that will restart the proxy asks for this. Restoring native Codex here + * replaced the routed catalog with the pristine one, and a Codex process that loaded it + * before the new proxy's first publication kept treating routed models as unknown until + * it was restarted. Like 80, the receipt stays outstanding, and the stop proved before + * choosing this code that it is the only obligation left; unlike 80, nothing refused the + * restore. The replacement discharges the receipt after it publishes, and a stop finishes + * it if no replacement ever starts. + */ +export const STOP_TEARDOWN_HANDED_OVER_EXIT_CODE = 81; diff --git a/src/update/stop-decision.d.mts b/src/update/stop-decision.d.mts index 13f1cd93caf..5fe7fab572f 100644 --- a/src/update/stop-decision.d.mts +++ b/src/update/stop-decision.d.mts @@ -6,5 +6,5 @@ export declare function decidePostStopUpdate(input: { teardownOutstanding?: boolean; }): { proceed: boolean; - reason: "stop-failed" | "runtime-state" | "teardown-outstanding" | "proxy-live" | "proxy-unknown" | "history-only" | "history-deferred" | "ok"; + reason: "stop-failed" | "runtime-state" | "teardown-outstanding" | "proxy-live" | "proxy-unknown" | "history-only" | "history-deferred" | "handed-over" | "ok"; }; diff --git a/src/update/stop-decision.mjs b/src/update/stop-decision.mjs index 0cc1a71a593..3c021a63a41 100644 --- a/src/update/stop-decision.mjs +++ b/src/update/stop-decision.mjs @@ -1,4 +1,8 @@ -import { STOP_HISTORY_DEFERRED_EXIT_CODE, STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "./stop-contract.mjs"; +import { + STOP_HISTORY_DEFERRED_EXIT_CODE, + STOP_HISTORY_INCOMPLETE_EXIT_CODE, + STOP_TEARDOWN_HANDED_OVER_EXIT_CODE, +} from "./stop-contract.mjs"; /** * May an update replace package files after `ocx stop` returned? @@ -29,15 +33,21 @@ import { STOP_HISTORY_DEFERRED_EXIT_CODE, STOP_HISTORY_INCOMPLETE_EXIT_CODE } fr * gate still applies: runtime records and a live or unreadable endpoint abort exactly * as they do for a clean stop, because package replacement under a live server is the * danger this function exists to prevent, and a history refusal says nothing about it. + * - `handed-over` — proceed; the stop is down and kept its receipt on purpose so the + * proxy this update restarts can take the routing over without a native gap (#7423). + * It is gated exactly like `history-deferred`: the child proved its receipt is the only + * obligation, and runtime records or a live or unreadable endpoint still abort. */ export function decidePostStopUpdate({ status, hasRuntimeState, liveness, teardownOutstanding = false }) { const historyOnly = status === STOP_HISTORY_INCOMPLETE_EXIT_CODE; const historyDeferred = status === STOP_HISTORY_DEFERRED_EXIT_CODE; - if (status !== 0 && !historyOnly && !historyDeferred) return { proceed: false, reason: "stop-failed" }; + const handedOver = status === STOP_TEARDOWN_HANDED_OVER_EXIT_CODE; + if (status !== 0 && !historyOnly && !historyDeferred && !handedOver) return { proceed: false, reason: "stop-failed" }; if (hasRuntimeState) return { proceed: false, reason: "runtime-state" }; - if (teardownOutstanding && !historyDeferred) return { proceed: false, reason: "teardown-outstanding" }; + if (teardownOutstanding && !historyDeferred && !handedOver) return { proceed: false, reason: "teardown-outstanding" }; if (liveness === "live") return { proceed: false, reason: "proxy-live" }; if (liveness !== "dead") return { proceed: false, reason: "proxy-unknown" }; if (historyDeferred) return { proceed: true, reason: "history-deferred" }; + if (handedOver) return { proceed: true, reason: "handed-over" }; return { proceed: true, reason: historyOnly ? "history-only" : "ok" }; } diff --git a/src/update/teardown-handover.ts b/src/update/teardown-handover.ts new file mode 100644 index 00000000000..65ec682e9c6 --- /dev/null +++ b/src/update/teardown-handover.ts @@ -0,0 +1,166 @@ +import { + clearPendingTeardown, + isPendingTeardownAbandoned, + listPendingTeardowns, + pendingTeardownPathFor, + pendingTeardownsAreExactly, + readPendingTeardown, +} from "../config/pending-teardown"; +import { isLikelyOcxProcess } from "../config/process-state"; +import { isProcessAlive } from "../lib/process-control"; + +/** + * Update teardown handover (#7423). + * + * An update stops the proxy before replacing the package. That stop used to restore native + * Codex, which puts the pristine catalog back and removes every routed row. The new proxy + * appends them again when it starts, but a Codex process that loads the catalog in between + * keeps treating routed models as unknown until it is restarted. On a long-context model + * that silent fallback metadata turned into a compaction loop. + * + * When the caller is going to restart the proxy, it sets this variable on the update + * child. The `ocx stop` that the update runs then keeps its teardown receipt instead of + * restoring, and the proxy that honours the deferral keeps its client config on exit. + * Ownership stays explicit: the receipt is the same durable obligation any deferred stop + * writes, so a replacement that never starts leaves it for the next `ocx stop` to finish. + * + * An ordinary `ocx stop` never sees the variable and still restores native routing. + */ +export const UPDATE_TEARDOWN_HANDOVER_ENV = "OCX_UPDATE_TEARDOWN_HANDOVER"; + +/** + * `publishesOnStart` answers whether the restarted proxy will run its startup Codex sync. + * Without one it can never discharge the receipt, so the stop restores as usual; an + * unreadable answer is treated the same way. + */ +export function updateTeardownHandoverRequested( + env: NodeJS.ProcessEnv = process.env, + publishesOnStart: () => boolean = () => true, +): boolean { + if (env[UPDATE_TEARDOWN_HANDOVER_ENV] !== "1") return false; + try { return publishesOnStart(); } catch { return false; } +} + +/** An identity-aware "is that stop still running", the rule handleStop uses for receipts. */ +export function teardownOwnerStillRunning(pid: number): boolean { + return isProcessAlive(pid) && isLikelyOcxProcess(pid); +} + +/** + * Is an update handover waiting for this starting proxy? True when a valid, exact handover + * receipt exists whose stop has exited: that stop kept Codex routing for the replacement. + */ +export function updateTeardownHandoverPending(ownerStillRunning: (pid: number) => boolean = teardownOwnerStillRunning): boolean { + return listPendingTeardowns().some(read => read.state === "valid" + && read.receipt.handover === "update-restart" && read.receipt.endpointSource === "exact" + && isPendingTeardownAbandoned(read, ownerStillRunning)); +} + +/** The environment for an update child whose caller will restart the proxy afterwards. */ +export function withUpdateTeardownHandover(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + return { ...env, [UPDATE_TEARDOWN_HANDOVER_ENV]: "1" }; +} + +/** The environment for an update child that must restore as usual, whatever this process inherited. */ +export function withoutUpdateTeardownHandover(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + const child = { ...env }; + delete child[UPDATE_TEARDOWN_HANDOVER_ENV]; + return child; +} + +/** + * Drop the marker from a starting proxy. The updater lanes pass their environment to the + * recovery and replacement starts they spawn; a proxy that kept it would hand it to every + * dashboard update worker it launches later, including ones that never restart. + */ +export function forgetUpdateTeardownHandover(env: NodeJS.ProcessEnv = process.env): void { + delete env[UPDATE_TEARDOWN_HANDOVER_ENV]; +} + +/** The facts `ocx stop` has already settled when it reaches the shared restore. */ +export type StopHandoverFacts = { + requested: boolean; + restoreBlocked: boolean; + nonce: string | undefined; + stopFailed: boolean; + recoveredCount: number; + proxy: string; + /** What the service-manager stop returned; a manager can take the proxy down itself. */ + service?: string; + /** + * The proxy this run stopped answered `deferred` before exiting. Any fallback (a failed + * or unconfirmed /api/stop, then a signal) lets a standalone proxy's exit cleanup restore + * native Codex and the pristine catalog, so nothing is left to hand over. + */ + deferralHonored?: boolean; +}; + +/** + * May this stop skip the restore and hand its teardown to the restarted proxy? + * + * Only when the update asked for it, this run's own receipt is the single obligation in + * the home, and the proxy it named is down: stopped by this run, or gone after a clean + * service-manager stop (a launchd/systemd/scheduler child exits with its manager, and its + * own exit cleanup keeps routing because it runs with OCX_SERVICE). A recovered or foreign + * receipt, a blocked restore or a stop that failed on the way all take the ordinary + * restore instead. The exclusivity check is what lets the updater treat the outstanding + * receipt as expected; the updater still probes the endpoint itself before replacing files. + */ +export function stopHandsTeardownOver(facts: StopHandoverFacts): boolean { + const managerStopped = facts.service === "stopped" || facts.service === "stopped-respawnable"; + const proxyDown = ((facts.proxy === "stopped" || facts.proxy === "stopped-orphan") && facts.deferralHonored === true) + || (facts.proxy === "not-running" && managerStopped); + return facts.requested && !facts.restoreBlocked && facts.nonce !== undefined && !facts.stopFailed + && facts.recoveredCount === 0 && proxyDown && pendingTeardownsAreExactly([facts.nonce]) + && handoverReceiptIsRecoverable(facts.nonce); +} + +/** + * A handed-over receipt must be one a later `ocx stop` can finish on its own if no + * replacement ever starts. A `guessed` endpoint fails closed into manual recovery there, + * so a stop that could only record the configured address restores instead. + */ +function handoverReceiptIsRecoverable(nonce: string): boolean { + const read = readPendingTeardown(nonce); + return read.state === "valid" && read.receipt.endpointSource === "exact" && read.receipt.handover === "update-restart"; +} + +export function announceStopHandover(log: Pick = console): void { + log.log("↪️ Codex routing, its catalog and the Grok config stay in place for the updated proxy, which releases them after its first sync."); + log.log(" If it does not start, run 'ocx start' to take them over or 'ocx stop' to restore native Codex."); +} + +/** + * Release handover receipts once this proxy has published its own routing. + * + * Called after a successful startup sync, when Codex config and catalog point at the + * running proxy again, so the restore the stop handed over is no longer owed. Only a + * handover receipt whose owning stop has exited is released: an ordinary deferred stop + * still has to run its own restore, and a stop that is still running owns its receipt. + */ +export function dischargeUpdateTeardownHandovers(ownerStillRunning: (pid: number) => boolean): { + discharged: string[]; + failed: string[]; +} { + const discharged: string[] = []; + const failed: string[] = []; + for (const read of listPendingTeardowns()) { + if (read.state !== "valid" || read.receipt.handover !== "update-restart") continue; + if (!isPendingTeardownAbandoned(read, ownerStillRunning)) continue; + if (clearPendingTeardown(read.receipt.nonce)) discharged.push(read.receipt.nonce); + else failed.push(read.receipt.nonce); + } + return { discharged, failed }; +} + +/** Startup half: after this proxy published its own routing, release what the update kept. */ +export function completeUpdateTeardownHandover( + ownerStillRunning: (pid: number) => boolean, + log: Pick = console, +): void { + const handover = dischargeUpdateTeardownHandovers(ownerStillRunning); + if (handover.discharged.length > 0) log.log(" Took over the Codex routing an update kept in place."); + for (const nonce of handover.failed) { + log.error(`⚠️ Could not remove the update handover receipt ${pendingTeardownPathFor(nonce)}; the next 'ocx stop' finishes it.`); + } +} diff --git a/structure/runtime.md b/structure/runtime.md index ebeac303a54..35e50832d4e 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -59,7 +59,7 @@ The [CLI management contract](cli-management.md#cli-readiness-diagnostics) owns `ocx resolve` (`src/cli/resolve.ts`) is the machine surface a desktop shell asks instead of resolving the config home, the port, and liveness itself: the home comes from `src/config/paths.ts`, the effective port is the live listener's when the identity-checked `findLiveProxy` answers and the configured `config.port ?? 10100` otherwise, and the liveness verdict is that same module's output (pid, runtime-versus-config provenance, version, role). Config reads go through `readConfigDiagnostics`, not `loadConfig`: a missing file is defaults, but an invalid file exits 1 instead of being repaired to defaults, because a defaulted port is a guess the caller must refuse. Liveness is three-valued: when `findLiveProxy` returns null, resolve re-asks the endpoints with `endpointsToProve` and `everyEndpointProvenDown`, using the in-process `probeEndpointLiveness` from `src/server/proxy-liveness.ts`. Only unanimous definitive "dead" becomes `absent-proven`; a timeout or reset stays unknown, exits 1 and never authorises a start. The transient exclusive-bind fallback in `src/update/proxy-liveness-probe.mjs` belongs only to the npm/Bun updater. Resolve stays in-process because a compiled standalone binary cannot run a JavaScript probe with `execPath -e`. Discovery borrows `START_OWNERSHIP_LIVENESS`, the start path's ownership budget — the verdict feeds the shell's launch decision, so the cost of a false "nobody listening" is the duplicate proxy (#5004). The verb is in `skipsCodexShimAutoRestore`, so a read-only lookup never triggers a shim repair. Arguments are pre-parsed in `src/cli/root.ts` and exit 64 before any preflight side effect, the same ordering `ocx ready` obeys. `ocx-resolve/1` also reports `ownership` (`none`, `owned`, or `unknown`) and `takeover` (`supported` with protocol version, minimum CLI version and compatibility token, or `blocked` with a reason). A known liveness verdict still exits 0 when ownership is unknown, but takeover is blocked. An unreadable second service-state read blocks takeover instead of becoming an absent registration. `src/service/managing-cli.ts` observes the registered and selected PATH CLIs, including Windows PATHEXT order and file validation, before compatibility is offered. A `--version` probe is retried once on Windows — one transient failure must not read as an unobservable CLI — and a Windows `.CMD`/`.BAT` shim is invoked through a single verbatim-quoted `cmd /c` line, so a spaced executable path and spaced arguments both survive cmd's re-parse. `src/cli/route-policy.ts` distinguishes a missing profile (exit 4) from malformed `show` arguments (exit 2); an upstream failure propagates through the management error mapping. -`ocx stop --json` is a reporting layer over the unchanged stop path. `src/cli/index.ts` threads a `StopRunRecord` through the existing receipt, drain, respawn-verification and restore flow, and `src/cli/stop-report.ts` maps the recorded facts plus the signals that already pick the exit code into one versioned document (`schema: "ocx-stop/1"`). With `--json` the human lines print on stderr and stdout carries only that document; exit codes 0/1/79/80 cross the process boundary unchanged. The desktop's opt-in guarded stop adds exact approved PID, endpoint, home, CLI version and compatibility-token expectations; plain `ocx stop` retains its ordinary behavior. Under the ownership mutation lease, `src/cli/stop-approval.ts` re-resolves them and `src/service/guarded-manager-target.ts` binds any installed manager's process tree to the approved PID before a stop action. A pre-action mismatch returns `approval-changed` without stopping, and the summary carries `detail` naming the single guard fact that failed — live PID/port/hostname/CLI drift, an unverifiable ownership claim, a blocked takeover, or the compatibility fingerprint — so a transient re-probe can be told from genuine drift in the caller's log. Immediately before any manager stop or direct PID signal, the CLI rechecks the same manager identity or proven absence; a change also returns `approval-changed`. A non-OpenCodex process replacing the OS job in the remaining instant before the manager command is a residual same-user risk outside the ownership lease. The guarded path stops the bound manager, or the approved PID directly when managers are proven absent, then uses a five-second shared deadline to verify PID exit, port availability and definitive endpoint absence. Only after settlement does a read-only manager probe require launchd `not-loaded` in both domains or systemd `inactive` with `MainPID=0`; the state is checked again before a success summary. Timeout, active or unreadable manager state returns terminal `manager-still-active`, even if the endpoint briefly refuses. Windows managers bind through `src/service/windows-guarded-manager.ts`: a Task Scheduler backend must prove a recognized OpenCodex registration against the bounded current-user SID/name lookup, a running task, and a wrapper ancestor carrying this home's canonical paths — presence and running state are read root-folder-scoped because every `/tn` operation resolves a bare name there and nowhere else, so a same-named task inside a task folder is a different registration neither the probe nor the stop can reach; the registered definition itself is read only on the running path, and a state or registration read that cannot answer re-probes presence so a task deleted between probes — which can respawn nothing — reads absent while a merely unreadable one stays unknown; an unreadable current identity leaves the manager unknown; a WinSW backend must prove this installation's binary and SCM service PID ancestry; either marks `childNeedsSeparateStop`, so the approved PID receives its own graceful signal only after a successful manager stop because `schtasks /end` does not reliably cascade to the wrapper's child (#764); a failed or unknown manager stop leaves the child untouched. Post-stop Windows inactivity means the task not running, no surviving wrapper process, and no started WinSW — never mere registration presence; a running-state read that cannot answer re-probes presence the same way, so a task deleted between probes reports inactive while a persisting one stays unknown. Every Windows manager command a verdict or stop depends on — schtasks, sc.exe, WinSW verbs — runs under a bounded `execFileSync` timeout so a wedged Task Scheduler or Service Control Manager cannot hang the guarded stop; a killed command classifies through the same fail-closed paths a nonzero exit would (the interactive `install /p` password prompt is the one deliberate exception). A `wscript.exe`, `cscript.exe` or `cmd.exe` whose command line CIM cannot read makes the state unprovable only when it is tied to the proxy: pre-stop, as an ancestor of the approved PID; post-stop, as the former bound manager process or a descendant of it. Unrelated unreadable shells, which a non-admin query returns for other users' and elevated processes, are not evidence either way. An unprovable Windows manager still blocks guarded takeover. The token checks snapshot consistency, not whether a person approved the desktop prompt. `src/cli/stop-restore.ts` owns the shared Codex/Grok restoration and its history-only, history-deferred, and other failure categories. +`ocx stop --json` is a reporting layer over the unchanged stop path. `src/cli/index.ts` threads a `StopRunRecord` through the existing receipt, drain, respawn-verification and restore flow, and `src/cli/stop-report.ts` maps the recorded facts plus the signals that already pick the exit code into one versioned document (`schema: "ocx-stop/1"`). With `--json` the human lines print on stderr and stdout carries only that document; exit codes 0/1/79/80/81 cross the process boundary unchanged. The desktop's opt-in guarded stop adds exact approved PID, endpoint, home, CLI version and compatibility-token expectations; plain `ocx stop` retains its ordinary behavior. Under the ownership mutation lease, `src/cli/stop-approval.ts` re-resolves them and `src/service/guarded-manager-target.ts` binds any installed manager's process tree to the approved PID before a stop action. A pre-action mismatch returns `approval-changed` without stopping, and the summary carries `detail` naming the single guard fact that failed — live PID/port/hostname/CLI drift, an unverifiable ownership claim, a blocked takeover, or the compatibility fingerprint — so a transient re-probe can be told from genuine drift in the caller's log. Immediately before any manager stop or direct PID signal, the CLI rechecks the same manager identity or proven absence; a change also returns `approval-changed`. A non-OpenCodex process replacing the OS job in the remaining instant before the manager command is a residual same-user risk outside the ownership lease. The guarded path stops the bound manager, or the approved PID directly when managers are proven absent, then uses a five-second shared deadline to verify PID exit, port availability and definitive endpoint absence. Only after settlement does a read-only manager probe require launchd `not-loaded` in both domains or systemd `inactive` with `MainPID=0`; the state is checked again before a success summary. Timeout, active or unreadable manager state returns terminal `manager-still-active`, even if the endpoint briefly refuses. Windows managers bind through `src/service/windows-guarded-manager.ts`: a Task Scheduler backend must prove a recognized OpenCodex registration against the bounded current-user SID/name lookup, a running task, and a wrapper ancestor carrying this home's canonical paths — presence and running state are read root-folder-scoped because every `/tn` operation resolves a bare name there and nowhere else, so a same-named task inside a task folder is a different registration neither the probe nor the stop can reach; the registered definition itself is read only on the running path, and a state or registration read that cannot answer re-probes presence so a task deleted between probes — which can respawn nothing — reads absent while a merely unreadable one stays unknown; an unreadable current identity leaves the manager unknown; a WinSW backend must prove this installation's binary and SCM service PID ancestry; either marks `childNeedsSeparateStop`, so the approved PID receives its own graceful signal only after a successful manager stop because `schtasks /end` does not reliably cascade to the wrapper's child (#764); a failed or unknown manager stop leaves the child untouched. Post-stop Windows inactivity means the task not running, no surviving wrapper process, and no started WinSW — never mere registration presence; a running-state read that cannot answer re-probes presence the same way, so a task deleted between probes reports inactive while a persisting one stays unknown. Every Windows manager command a verdict or stop depends on — schtasks, sc.exe, WinSW verbs — runs under a bounded `execFileSync` timeout so a wedged Task Scheduler or Service Control Manager cannot hang the guarded stop; a killed command classifies through the same fail-closed paths a nonzero exit would (the interactive `install /p` password prompt is the one deliberate exception). A `wscript.exe`, `cscript.exe` or `cmd.exe` whose command line CIM cannot read makes the state unprovable only when it is tied to the proxy: pre-stop, as an ancestor of the approved PID; post-stop, as the former bound manager process or a descendant of it. Unrelated unreadable shells, which a non-admin query returns for other users' and elevated processes, are not evidence either way. An unprovable Windows manager still blocks guarded takeover. The token checks snapshot consistency, not whether a person approved the desktop prompt. `src/cli/stop-restore.ts` owns the shared Codex/Grok restoration and its history-only, history-deferred, and other failure categories. `ocx service status --json` (`src/service/status-json.ts`) is the passive machine form of the service report, read by peers such as GOAT's device inventory. `src/service/cli.ts` accepts `--json` for `status` only (`planServiceCommand` refuses it on every other verb before any installation probe) and answers it before `platformOps()` resolves, so no install, start, stop, repair or migration path is reachable; `skipsCodexShimAutoRestore` exempts `service status --json` so the CLI preflight repairs no Codex shim first, the collection runs inside `runPassiveRead` so the serving probe's config load neither hardens modes nor backs up a malformed file, and `--json` on any other verb is refused before the Desktop check and the Windows mutation lock; an unsupported platform or Docker gets a document instead of the text path's exit. The document reuses exactly the reads the text report makes — `diagnoseService()` (launchd `print`, systemd observation verbs, Task Scheduler `/query`, SCM/WinSW status) and, only when a registration exists, the 1.5-second loopback `confirmServiceServing` probe — plus the ownership-lease and Desktop login lines. `running` is the manager's verdict (the value `startup.serviceRunning` carries in `ocx status --json`); `serving` reports separately whether a proxy answers on the installed port. `backend` is `launchd`, `systemd`, `schtasks`, `winsw`, or `none` when nothing is registered. The systemd branch of `diagnoseService` derives through the pure `deriveSystemdServiceDiagnostic`, matching the launchd and Windows derivations. `tests/service/service-status-json.test.ts` snapshots each platform branch and records every subprocess entry point on darwin, linux and win32 against a read-only allowlist. `ocx status --json` carries an additive `install` verdict (`src/cli/status-install.ts`) mapped from the updater's own `detectInstallOwnership()`: `method` is `bun`, `npm`, `pnpm`, `mise`, `source`, `standalone` or `unknown` (detector failure), and `evidence.basis` names the package layout, the mise backend record (tool alias and backend only) or the standalone binary without printing a path (`tests/cli/status-install-method.test.ts`). @@ -275,7 +275,7 @@ with no proxy running and a dead owner (#4897). `isLikelyOcxProcess` asks the br `verifyPidIdentity`, without the `start` verb, because a receipt owner is an `ocx stop` or the `ocx update` worker that drove it rather than the proxy. Recognizing a receipt as abandoned only admits it to recovery; a valid receipt must still prove its recorded endpoint is down before -anything is restored, and the package launcher still decides nothing itself. +anything is restored, and the package launcher still decides nothing itself. An update whose dashboard job will restart the proxy runs that stop with `OCX_UPDATE_TEARDOWN_HANDOVER=1` (#7423): the receipt is marked `handover: "update-restart"`, the stop exits 81 without restoring only when that receipt is the single obligation in the home, the old proxy honoring the deferral exits as a recycle so its exit cleanup keeps Codex config and the routed catalog, both updater lanes proceed past exit 81 under the same gates as 80, and the replacement discharges abandoned handover receipts after its first published startup sync (`src/update/teardown-handover.ts`). A replacement that never starts leaves an ordinary abandoned receipt for the next `ocx stop`. While such a receipt is pending, startup journal recovery adopts the previous proxy's process-owned journal instead of replaying native config before the first sync (`reconcileJournal({ adoptUpdateHandover })`). Port reclamation must honor a rejected OCX verifier result even for a PID captured before stop or update. A rejected live holder prevents both termination and TCP-row deletion for that scan; later diff --git a/tests/cli/cli-dispatch.test.ts b/tests/cli/cli-dispatch.test.ts index 544ca90c7fc..17a90be01dd 100644 --- a/tests/cli/cli-dispatch.test.ts +++ b/tests/cli/cli-dispatch.test.ts @@ -632,7 +632,8 @@ describe("a sibling start leaves shared client routing to the live owner", () => // A sibling never runs under a service manager, so an installed one is the live owner's. Asking // it failed the ownership check from the sibling's home: exit 1, and after a hard kill the // stale sibling records were never purged. - expect(stop).toContain(': stoppingSibling || connectedClientAlreadyStopped ? "absent" : stopServiceIfInstalledDetailed();'); + // #7423: the non-sibling branch claims an update-handover receipt first; the sibling term is unchanged. + expect(stop).toContain(': stoppingSibling || connectedClientAlreadyStopped ? "absent" : (claimHandoverBeforeManager(), stopServiceIfInstalledDetailed());'); // The only other caller is the guarded desktop step, which reaches the manager only when it // provably owns the approved pid; a sibling's pid never is. expect(stop.split("stopServiceIfInstalledDetailed()").length - 1).toBe(2); diff --git a/tests/fixtures/handover-journal-probe.ts b/tests/fixtures/handover-journal-probe.ts new file mode 100644 index 00000000000..2e728dd91cb --- /dev/null +++ b/tests/fixtures/handover-journal-probe.ts @@ -0,0 +1,50 @@ +/** #7423 startup journal recovery probe: runs the real reconcileStartupJournal in an isolated child. */ +import { spyOn } from "bun:test"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +const [mode, root] = [process.argv[2] as "swap" | "off", process.argv[3]!]; +const codex = join(root, "codex"); +const ocx = join(root, "ocx"); +for (const path of [codex, ocx]) mkdirSync(path, { recursive: true }); +process.env.CODEX_HOME = codex; +process.env.OPENCODEX_HOME = ocx; +if (mode === "off") writeFileSync(join(ocx, "config.json"), JSON.stringify({ providers: {}, clientIntegrations: { codex: false } })); +const original = "# original\nmodel_provider = \"openai\"\n"; +const injected = "# injected\nmodel_provider = \"opencodex\"\n"; +const configPath = join(codex, "config.toml"); +const journalPath = join(codex, "opencodex-journal.json"); +const dead = 999_999_991; +const journal = { + version: 1, originalConfig: Buffer.from(original).toString("base64"), originalProfile: null, + injectedConfigHash: createHash("sha256").update(injected).digest("hex"), injectedProfileHash: null, + pid: dead, owner: { kind: "process", pid: dead }, opencodexHome: ocx, timestamp: new Date().toISOString(), +}; +writeFileSync(configPath, injected); +writeFileSync(journalPath, JSON.stringify(journal)); +const receipts = await import("../../src/config/pending-teardown"); +// A receipt left by an update whose stop exited long ago. +receipts.claimPendingTeardown({ hostname: "127.0.0.1", port: 10100 }, "exact", dead + 1, "update-restart"); +// A replacement home bound to another live owner, installed after the journal was read. +const replacement = { ...journal, originalConfig: Buffer.from("# replacement baseline\n").toString("base64"), pid: process.pid, owner: { kind: "process", pid: process.pid } }; +const kill = process.kill; +const spy = spyOn(process, "kill").mockImplementation(((pid: number, signal?: NodeJS.Signals | number) => { + if ((pid !== dead && pid !== dead + 1) || signal !== 0) return kill.call(process, pid, signal); + if (mode === "swap" && pid === dead) { + renameSync(codex, join(root, "parked-codex")); + mkdirSync(codex); + writeFileSync(configPath, injected); + writeFileSync(journalPath, JSON.stringify(replacement)); + } + throw Object.assign(new Error("dead fixture owner"), { code: "ESRCH" }); +}) as typeof process.kill); +try { + (await import("../../src/cli/cross-home-owner")).reconcileStartupJournal(); +} finally { + spy.mockRestore(); +} +console.log(JSON.stringify(mode === "swap" + ? { replacementPreserved: readFileSync(journalPath, "utf8") === JSON.stringify(replacement) } + : { config: readFileSync(configPath, "utf8"), journalExists: existsSync(journalPath) })); + diff --git a/tests/fixtures/parent-stop-runner.ts b/tests/fixtures/parent-stop-runner.ts index 93ef7fae037..7f67e822a24 100644 --- a/tests/fixtures/parent-stop-runner.ts +++ b/tests/fixtures/parent-stop-runner.ts @@ -25,6 +25,12 @@ const options = JSON.parse(readFileSync(0, "utf8")) as { alreadyStopped?: boolean; absence?: "dead" | "unknown" | "live"; managerState?: "inactive" | "active" | "unknown"; + /** #7423: run the stop the way an update that restarts the proxy runs it. */ + handover?: boolean; + /** The runtime-port record is missing, so the stop can only record a guessed endpoint. */ + noRuntimeRecord?: boolean; + /** Like the real readPid, report no PID once the process is gone (a manager stop exited it). */ + deadPidReadsNull?: boolean; }; if (options.proxyProbePort) { // Deliberately bypass every fixture mock for the real proxy-environment regression. @@ -100,8 +106,8 @@ mock.module("../../src/config/pending-teardown", () => ({ const state = await import("../../src/config/process-state"); mock.module("../../src/config/process-state", () => ({ ...state, - readPid: () => options.orphan || options.alreadyStopped ? null : fakePid, - readRuntimePort: () => options.alreadyStopped ? null : endpoint, + readPid: () => options.orphan || options.alreadyStopped || (options.deadPidReadsNull && !alive) ? null : fakePid, + readRuntimePort: () => options.alreadyStopped || options.noRuntimeRecord ? null : endpoint, removePid() {}, removeRuntimePort() {}, })); @@ -198,6 +204,7 @@ mock.module("../../src/server/direct-local-http", () => ({ ...direct, })); process.argv = [process.execPath, "ocx", "stop", ...(options.json ? ["--json"] : [])]; +if (options.handover) process.env.OCX_UPDATE_TEARDOWN_HANDOVER = "1"; if (options.exitCleanup) { delete process.env.OCX_SERVICE; // Execute the actual startup exit closure, with only host/config effects replaced. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index c7858ea3657..f8c40421f3d 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1283,6 +1283,7 @@ "update-mise-node-runtime.test.ts": "update", "update-pnpm.test.ts": "update", "update-refresh.test.ts": "update", "update-restart-lease.test.ts": "update", "update-stop-classification.test.ts": "update", "update-stop-first.test.ts": "update", + "update-teardown-handover.test.ts": "update", "update-transactional-leftovers.test.ts": "update", "update-transactional.test.ts": "update", "update-tray-handoff.test.ts": "update", "update-tree-ownership.test.ts": "update", "upstream-connect-error.test.ts": "server", "upstream-error-message-fallback.test.ts": "lib", diff --git a/tests/providers/xai/grok-lifecycle.test.ts b/tests/providers/xai/grok-lifecycle.test.ts index 8f1dbcae0a9..34abd0acd9a 100644 --- a/tests/providers/xai/grok-lifecycle.test.ts +++ b/tests/providers/xai/grok-lifecycle.test.ts @@ -333,10 +333,11 @@ describe("Grok fence lifecycle wiring", () => { const updateSource2 = readFileSync(repoPath("src", "update", "index.ts"), "utf8"); expect(updateSource2).toContain("teardownOutstanding: pendingTeardownOutstanding()"); const decisionSource = readFileSync(repoPath("src", "update", "stop-decision.mjs"), "utf8"); - // The gate has exactly one exemption, and it is the child saying it kept those - // receipts on purpose after the Codex history preflight refused (#4718). Anything - // else — including a stop that merely exited 0 — still aborts the install. - expect(decisionSource).toContain('if (teardownOutstanding && !historyDeferred) return { proceed: false, reason: "teardown-outstanding" };'); + // The gate has exactly two exemptions, both the child saying it kept its own receipt on + // purpose: after the Codex history preflight refused (#4718), and when an update hands + // its teardown to the proxy it restarts (#7423). Anything else — including a stop that + // merely exited 0 — still aborts the install. + expect(decisionSource).toContain('if (teardownOutstanding && !historyDeferred && !handedOver) return { proceed: false, reason: "teardown-outstanding" };'); expect(decisionSource).toContain("const historyDeferred = status === STOP_HISTORY_DEFERRED_EXIT_CODE;"); const receiptSource = readFileSync(repoPath("src", "config", "pending-teardown.ts"), "utf8"); expect(receiptSource).toContain('from "./pending-teardown-names.mjs"'); diff --git a/tests/update/update-job.test.ts b/tests/update/update-job.test.ts index 169f3abdde1..2e6c9b16da9 100644 --- a/tests/update/update-job.test.ts +++ b/tests/update/update-job.test.ts @@ -1852,7 +1852,7 @@ describe("immutable update target (WP160)", () => { const cacheGateAt = source.indexOf("const cachePreflight = (io.cachePreflightFn ?? runNpmCachePreflight)();"); const trayStopAt = source.indexOf("handoffWindowsTrayForUpdate(tray"); const failAt = source.indexOf('updateJob(job, { status: "failed", error: integrity.reason });'); - const spawnAt = source.indexOf("const result = (io.runCommandFn ?? runLoggedCommand)(job, cmd.bin, cmd.args, UPDATE_TIMEOUT_MS);"); + const spawnAt = source.indexOf("const result = (io.runCommandFn ?? runLoggedCommand)(job, cmd.bin, cmd.args, UPDATE_TIMEOUT_MS, updateEnv);"); expect(gateAt).toBeGreaterThan(-1); expect(cacheGateAt).toBeGreaterThan(-1); expect(trayStopAt).toBeGreaterThan(-1); diff --git a/tests/update/update-stop-classification.test.ts b/tests/update/update-stop-classification.test.ts index 18057c50ba5..779ba4c4d0d 100644 --- a/tests/update/update-stop-classification.test.ts +++ b/tests/update/update-stop-classification.test.ts @@ -382,7 +382,8 @@ describe("stop failure classification (#3008)", () => { .toEqual({ proceed: false, reason: "proxy-unknown" }); // And no neighbouring status inherits the exemption. - for (const status of [1, 2, 4, 64, 78, 81, 130, null]) { + // 81 is the update handover code (#7423), pinned in update-teardown-handover.test.ts. + for (const status of [1, 2, 4, 64, 78, 82, 130, null]) { expect(decidePostStopUpdate({ status, teardownOutstanding: true, ...dead })) .toEqual({ proceed: false, reason: "stop-failed" }); } diff --git a/tests/update/update-teardown-handover.test.ts b/tests/update/update-teardown-handover.test.ts new file mode 100644 index 00000000000..c2ac7b59d58 --- /dev/null +++ b/tests/update/update-teardown-handover.test.ts @@ -0,0 +1,399 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { CodexNativeRestoreResult } from "../../src/codex/inject"; +import { syncCodexOnStartIfEnabled } from "../../src/codex/desired-state"; +import { claimPendingTeardown, isUpdateHandoverReceipt, pendingTeardownPathFor, readPendingTeardown } from "../../src/config/pending-teardown"; +import { performStopTeardown } from "../../src/server/stop-teardown"; +import { runGuiUpdateWorker, updateJobPath } from "../../src/update/job"; +import { + STOP_HISTORY_DEFERRED_EXIT_CODE, + STOP_HISTORY_INCOMPLETE_EXIT_CODE, + STOP_TEARDOWN_HANDED_OVER_EXIT_CODE, +} from "../../src/update/stop-contract.mjs"; +import { decidePostStopUpdate } from "../../src/update/stop-decision.mjs"; +import { + dischargeUpdateTeardownHandovers, + forgetUpdateTeardownHandover, + stopHandsTeardownOver, + updateTeardownHandoverPending, + updateTeardownHandoverRequested, + UPDATE_TEARDOWN_HANDOVER_ENV, +} from "../../src/update/teardown-handover"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { fixturePath, repoPath } from "../helpers/repo-root"; + +/** + * #7423: an update restart left a window where Codex could load the pristine catalog. + * + * The update's `ocx stop` restored native Codex, which removed every routed catalog row, + * and the new proxy appended them again only after it started. A Codex process that loaded + * the catalog in between treated routed models as unknown until it was restarted. These + * cases pin the handover that closes the window: the stop keeps its receipt instead of + * restoring, the proxy keeps its client config on exit, the updater proceeds past that one + * receipt, and the replacement releases it after its first successful publication. + */ + +const ENDPOINT = { hostname: "127.0.0.1", port: 10100 }; +const DEAD = () => false; +let home: string; +let previousHome: string | undefined; + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-teardown-handover-")); + process.env.OPENCODEX_HOME = home; +}); + +afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(home); +}); + +const receiptNames = () => readdirSync(home).filter(name => name.startsWith("pending-teardown")); + +describe("handover exit code and update decision", () => { + test("81 is distinct from the history codes and from every literal code the CLI emits", () => { + expect(STOP_TEARDOWN_HANDED_OVER_EXIT_CODE).toBe(81); + expect([STOP_HISTORY_INCOMPLETE_EXIT_CODE, STOP_HISTORY_DEFERRED_EXIT_CODE]).not.toContain(81); + const cli = readFileSync(repoPath("src", "cli", "index.ts"), "utf8"); + const dispatch = readFileSync(repoPath("src", "cli", "dispatch.ts"), "utf8"); + const literal = [...cli.matchAll(/process\.exit(?:Code)?\s*(?:=|\()\s*(\d+)/g), ...dispatch.matchAll(/return (\d+);/g)] + .map(match => Number(match[1])); + expect(literal).not.toContain(81); + }); + + test("a handed-over stop proceeds past its own receipt and nothing else", () => { + const status = STOP_TEARDOWN_HANDED_OVER_EXIT_CODE; + expect(decidePostStopUpdate({ status, hasRuntimeState: false, liveness: "dead", teardownOutstanding: true })) + .toEqual({ proceed: true, reason: "handed-over" }); + expect(decidePostStopUpdate({ status, hasRuntimeState: true, liveness: "dead", teardownOutstanding: true }).reason).toBe("runtime-state"); + expect(decidePostStopUpdate({ status, hasRuntimeState: false, liveness: "live", teardownOutstanding: true }).reason).toBe("proxy-live"); + expect(decidePostStopUpdate({ status, hasRuntimeState: false, liveness: "unknown", teardownOutstanding: true }).reason).toBe("proxy-unknown"); + // An ordinary stop that leaves a receipt behind still aborts, exactly as before. + expect(decidePostStopUpdate({ status: 0, hasRuntimeState: false, liveness: "dead", teardownOutstanding: true }).reason) + .toBe("teardown-outstanding"); + }); +}); + +describe("stop handover predicate", () => { + const facts = (nonce: string | undefined) => ({ + requested: true, restoreBlocked: false, nonce, stopFailed: false, recoveredCount: 0, proxy: "stopped", deferralHonored: true, + }); + + test("hands over only this run's own receipt, alone, after a clean stop", () => { + const own = claimPendingTeardown(ENDPOINT, "exact", process.pid, "update-restart").nonce; + expect(stopHandsTeardownOver(facts(own))).toBe(true); + expect(stopHandsTeardownOver({ ...facts(own), proxy: "stopped-orphan" })).toBe(true); + // A signal fallback ran the proxy's own exit cleanup, which already restored the pristine catalog. + expect(stopHandsTeardownOver({ ...facts(own), deferralHonored: false })).toBe(false); + expect(stopHandsTeardownOver({ ...facts(own), proxy: "stopped-orphan", deferralHonored: false })).toBe(false); + expect(stopHandsTeardownOver({ ...facts(own), requested: false })).toBe(false); + expect(stopHandsTeardownOver({ ...facts(own), restoreBlocked: true })).toBe(false); + expect(stopHandsTeardownOver({ ...facts(own), stopFailed: true })).toBe(false); + expect(stopHandsTeardownOver({ ...facts(own), recoveredCount: 1 })).toBe(false); + expect(stopHandsTeardownOver({ ...facts(own), proxy: "not-running" })).toBe(false); + expect(stopHandsTeardownOver(facts(undefined))).toBe(false); + }); + + test("a clean service-manager stop that took the proxy down may hand over", () => { + const own = claimPendingTeardown(ENDPOINT, "exact", process.pid, "update-restart").nonce; + expect(stopHandsTeardownOver({ ...facts(own), proxy: "not-running", service: "stopped" })).toBe(true); + expect(stopHandsTeardownOver({ ...facts(own), proxy: "not-running", service: "stopped-respawnable" })).toBe(true); + expect(stopHandsTeardownOver({ ...facts(own), proxy: "not-running", service: "absent" })).toBe(false); + expect(stopHandsTeardownOver({ ...facts(own), proxy: "not-running", service: "failed" })).toBe(false); + }); + + test("another obligation in the home forces the ordinary restore", () => { + const own = claimPendingTeardown(ENDPOINT, "exact", process.pid, "update-restart").nonce; + claimPendingTeardown(ENDPOINT, "exact", 999_999); + expect(stopHandsTeardownOver(facts(own))).toBe(false); + }); + + test("a guessed or unmarked receipt is never handed over", () => { + // A later ocx stop refuses to finish a guessed receipt, so handing one over could + // leave Codex routed at a dead proxy with only a manual way out. + const guessed = claimPendingTeardown(ENDPOINT, "guessed", process.pid, "update-restart").nonce; + expect(stopHandsTeardownOver(facts(guessed))).toBe(false); + rmSync(pendingTeardownPathFor(guessed), { force: true }); + const unmarked = claimPendingTeardown(ENDPOINT, "exact", process.pid).nonce; + expect(stopHandsTeardownOver(facts(unmarked))).toBe(false); + }); +}); + +describe("handover receipts", () => { + test("the handover marker round-trips and an unknown marker invalidates the receipt", () => { + const handover = claimPendingTeardown(ENDPOINT, "exact", 4242, "update-restart"); + const ordinary = claimPendingTeardown(ENDPOINT, "exact", 4242); + expect(isUpdateHandoverReceipt(handover.nonce)).toBe(true); + expect(isUpdateHandoverReceipt(ordinary.nonce)).toBe(false); + expect(isUpdateHandoverReceipt(null)).toBe(false); + const path = pendingTeardownPathFor(ordinary.nonce); + writeFileSync(path, JSON.stringify({ ...ordinary, handover: "anything" })); + expect(readPendingTeardown(ordinary.nonce).state).toBe("invalid"); + }); + + test("startup discharge releases only abandoned handover receipts", () => { + const abandoned = claimPendingTeardown(ENDPOINT, "exact", 4242, "update-restart").nonce; + const ordinary = claimPendingTeardown(ENDPOINT, "exact", 4243).nonce; + const running = claimPendingTeardown(ENDPOINT, "exact", 4244, "update-restart").nonce; + const result = dischargeUpdateTeardownHandovers(pid => pid === 4244); + expect(result).toEqual({ discharged: [abandoned], failed: [] }); + expect(existsSync(pendingTeardownPathFor(abandoned))).toBe(false); + expect(existsSync(pendingTeardownPathFor(ordinary))).toBe(true); + expect(existsSync(pendingTeardownPathFor(running))).toBe(true); + expect(dischargeUpdateTeardownHandovers(DEAD).discharged).toEqual([running]); + }); +}); + +describe("proxy side of the stop", () => { + const restored: CodexNativeRestoreResult = { success: true, message: "restored" } as CodexNativeRestoreResult; + + for (const handover of [true, false]) { + test(`a deferred ${handover ? "handover" : "ordinary"} stop ${handover ? "keeps" : "does not keep"} client config on exit`, async () => { + const nonce = claimPendingTeardown(ENDPOINT, "exact", 4242, handover ? "update-restart" : undefined).nonce; + let kept = 0; + let restores = 0; + const body = await performStopTeardown(new URL(`http://127.0.0.1:10100/api/stop?deferSharedTeardown=1&teardownNonce=${nonce}`), { + keepClientConfigOnExit: () => { kept += 1; }, + restoreNativeCodex: async () => { restores += 1; return restored; }, + stripGrok: () => ({ ok: true, changed: false, message: "" }), + }); + expect(body.sharedTeardown).toBe("deferred"); + expect(kept).toBe(handover ? 1 : 0); + expect(restores).toBe(0); + }); + } +}); + +describe("ocx stop under an update handover", () => { + const restore = { + success: true, message: "native Codex restored", + artifacts: { config: { state: "restored" }, catalog: { state: "restored" }, history: { state: "restored" } }, + }; + + function runStop(handover: boolean, extra: Record = {}) { + const child = spawnSync(process.execPath, [fixturePath("parent-stop-runner.ts")], { + cwd: repoPath(), + env: { ...process.env, OPENCODEX_HOME: home }, + input: JSON.stringify({ receipt: true, serverTeardown: true, json: true, response: null, restore, handover, ...extra }), + encoding: "utf8", + timeout: 20_000, + windowsHide: true, + }); + expect(child.error, child.stderr).toBeUndefined(); + const report = JSON.parse(readFileSync(join(home, "parent-stop-result.json"), "utf8")) as { + calls: { native: number; grok: number; cleared: number; service: number }; + nonce?: string; receiptExists: boolean; unexpectedIo: string[]; + }; + expect(report.unexpectedIo, child.stderr).toEqual([]); + return { ...report, exitCode: child.status, stdout: child.stdout, stderr: child.stderr }; + } + + test("keeps Codex routing and the catalog for the replacement and exits 81", () => { + const run = runStop(true); + expect(run.exitCode, run.stderr).toBe(STOP_TEARDOWN_HANDED_OVER_EXIT_CODE); + // Boundary one of the issue: nothing restored the pristine catalog after the old proxy stopped. + expect(run.calls).toMatchObject({ native: 0, grok: 0, cleared: 0 }); + expect(run.receiptExists).toBe(true); + expect(isUpdateHandoverReceipt(run.nonce ?? null)).toBe(true); + expect(JSON.parse(run.stdout)).toMatchObject({ proxy: "stopped", sharedTeardown: "skipped", exitCode: 81 }); + expect(decidePostStopUpdate({ status: run.exitCode, hasRuntimeState: false, liveness: "dead", teardownOutstanding: true })) + .toEqual({ proceed: true, reason: "handed-over" }); + }); + + test("an ordinary stop still restores native Codex and releases its receipt", () => { + const run = runStop(false); + expect(run.exitCode, run.stderr).toBe(0); + expect(run.calls).toMatchObject({ native: 1, grok: 1, cleared: 1 }); + expect(run.receiptExists).toBe(false); + expect(receiptNames()).toEqual([]); + }); + + test("hands over when the service manager stops the proxy itself", () => { + const run = runStop(true, { managerCascades: true, deadPidReadsNull: true }); + expect(run.exitCode, run.stderr).toBe(STOP_TEARDOWN_HANDED_OVER_EXIT_CODE); + expect(run.calls).toMatchObject({ native: 0, grok: 0, cleared: 0, service: 1 }); + expect(isUpdateHandoverReceipt(run.nonce ?? null)).toBe(true); + }); + + test("a manager-stopped ordinary stop still restores", () => { + const run = runStop(false, { managerCascades: true, deadPidReadsNull: true }); + expect(run.exitCode, run.stderr).toBe(0); + expect(run.calls).toMatchObject({ native: 1, grok: 1 }); + expect(receiptNames()).toEqual([]); + }); + + test("without a runtime record the stop restores instead of handing over a guessed receipt", () => { + const run = runStop(true, { noRuntimeRecord: true, nativeSignal: true }); + expect(run.exitCode, run.stderr).toBe(0); + expect(run.calls).toMatchObject({ native: 1, grok: 1 }); + expect(receiptNames()).toEqual([]); + }); + + test("a failed /api/stop that falls back to a signal restores instead of claiming a handover", () => { + // Exact runtime endpoint, but the proxy answers 503 before honoring the deferral, so the + // stop falls back to a signal and the proxy's own exit cleanup is what ran. + const run = runStop(true, { apiFailure: true, status: 503, response: { error: "unavailable" }, nativeSignal: true }); + expect(run.exitCode, run.stderr).toBe(0); + expect(run.calls).toMatchObject({ native: 1, grok: 1 }); + expect(receiptNames()).toEqual([]); + }); +}); + +describe("startup journal recovery under a handover", () => { + test("a pending handover is a valid, exact, abandoned handover receipt", () => { + expect(updateTeardownHandoverPending(DEAD)).toBe(false); + claimPendingTeardown(ENDPOINT, "exact", 4242); + expect(updateTeardownHandoverPending(DEAD)).toBe(false); + const guessed = claimPendingTeardown(ENDPOINT, "guessed", 4242, "update-restart").nonce; + expect(updateTeardownHandoverPending(DEAD)).toBe(false); + rmSync(pendingTeardownPathFor(guessed), { force: true }); + claimPendingTeardown(ENDPOINT, "exact", 4242, "update-restart"); + expect(updateTeardownHandoverPending(() => true)).toBe(false); + expect(updateTeardownHandoverPending(DEAD)).toBe(true); + }); + + test("the stop asks for a handover only when the replacement will run its startup sync", () => { + const env = { [UPDATE_TEARDOWN_HANDOVER_ENV]: "1" }; + expect(updateTeardownHandoverRequested(env, () => true)).toBe(true); + expect(updateTeardownHandoverRequested(env, () => false)).toBe(false); + expect(updateTeardownHandoverRequested(env, () => { throw new Error("unreadable config"); })).toBe(false); + expect(updateTeardownHandoverRequested({}, () => true)).toBe(false); + }); + + // The restarted proxy reconciles the previous proxy's journal before its first sync. Its + // owner PID is dead, so without adoption it restores native config for that interval. + for (const handover of [true, false]) { + test(`startup ${handover ? "adopts" : "restores"} a dead proxy's journal ${handover ? "with" : "without"} a pending handover`, () => { + const codexHome = mkdtempSync(join(tmpdir(), "ocx-handover-codex-")); + try { + const configPath = join(codexHome, "config.toml"); + const journalPath = join(codexHome, "opencodex-journal.json"); + const original = '# original\nmodel_provider = "openai"\n'; + const injected = '# injected\nmodel_provider = "opencodex"\n'; + writeFileSync(configPath, injected); + writeFileSync(journalPath, JSON.stringify({ + version: 1, + originalConfig: Buffer.from(original).toString("base64"), + originalProfile: null, + injectedConfigHash: createHash("sha256").update(injected).digest("hex"), + injectedProfileHash: null, + owner: { kind: "process", pid: 999_999 }, + pid: 999_999, + timestamp: new Date().toISOString(), + })); + if (handover) claimPendingTeardown(ENDPOINT, "exact", 999_998, "update-restart"); + const child = spawnSync(process.execPath, ["--eval", + 'const { reconcileStartupJournal } = require("./src/cli/cross-home-owner"); reconcileStartupJournal(); console.log(JSON.stringify({ pid: process.pid }));'], { + cwd: repoPath(), + env: { ...process.env, CODEX_HOME: codexHome, OPENCODEX_HOME: home }, + encoding: "utf8", + timeout: 60_000, + windowsHide: true, + }); + expect(child.status, child.stderr).toBe(0); + const childPid = (JSON.parse(child.stdout.trim().split("\n").at(-1)!) as { pid: number }).pid; + if (handover) { + expect(readFileSync(configPath, "utf8")).toBe(injected); + const journal = JSON.parse(readFileSync(journalPath, "utf8")) as { pid: number; owner: { pid: number }; originalConfig: string }; + expect(journal.pid).toBe(childPid); + expect(journal.owner.pid).toBe(childPid); + // The user's baseline survives the ownership change, so a later stop restores it. + expect(Buffer.from(journal.originalConfig, "base64").toString("utf8")).toBe(original); + } else { + expect(readFileSync(configPath, "utf8")).toBe(original); + } + } finally { + removeTreeWithRetry(codexHome); + } + }); + } + + function probe(mode: "swap" | "off"): Record { + const root = mkdtempSync(join(tmpdir(), `ocx-handover-probe-${mode}-`)); + try { + const child = spawnSync(process.execPath, [fixturePath("handover-journal-probe.ts"), mode, root], { + cwd: repoPath(), env: { ...process.env }, encoding: "utf8", timeout: 60_000, windowsHide: true, + }); + expect(child.status, child.stderr).toBe(0); + return JSON.parse(child.stdout.trim().split("\n").at(-1)!) as Record; + } finally { + removeTreeWithRetry(root); + } + } + + test("adoption never overwrites a Codex home replaced during recovery", () => { + expect(probe("swap")).toEqual({ replacementPreserved: true }); + }); + + test("a stale handover receipt does not keep routing once the Codex integration is off", () => { + expect(probe("off")).toEqual({ config: '# original\nmodel_provider = "openai"\n', journalExists: false }); + }); +}); + +describe("startup publication", () => { + const config = { providers: {} } as unknown as OcxConfig; + const cases: Array<[string, Record | undefined, boolean]> = [ + ["applied", { ok: true, status: "applied" }, true], + ["catalog-only", { ok: true, status: "catalog-only" }, true], + ["skipped", { ok: true, status: "skipped" }, false], + ["failed", { ok: false, status: "applied" }, false], + ["thrown", undefined, false], + ]; + for (const [name, outcome, published] of cases) { + test(`a ${name} startup sync ${published ? "publishes" : "does not publish"}`, async () => { + const result = await syncCodexOnStartIfEnabled(10100, config, async () => { + if (!outcome) throw new Error("sync failed"); + return outcome as never; + }); + expect(result.published).toBe(published); + }); + } +}); + +describe("dashboard update job", () => { + for (const restart of [true, false]) { + test(`${restart ? "requests" : "does not request"} the handover when restart=${restart}`, async () => { + let updateEnv: NodeJS.ProcessEnv | undefined; + // A worker launched by a recovery-started proxy could have inherited the marker. + process.env[UPDATE_TEARDOWN_HANDOVER_ENV] = "1"; + try { + await runGuiUpdateWorker(`handover-${restart}`, "latest", restart, { + checkForUpdateFn: () => ({ + currentVersion: "2.7.40", latestVersion: "2.7.41", channel: "latest", installer: "pnpm", + updateAvailable: true, canUpdate: true, command: "node /old/bin/ocx.mjs update --tag latest", + releaseNotesUrl: "https://github.com/lidge-jun/opencodex/releases/latest", + }), + resolvePnpmOwnerFn: () => ({ ok: true as const, owner: { + commandPath: "/pnpm/owner/bin/pnpm", packagePath: "/pnpm/owner/global/v11/node_modules/@bitkyc08/opencodex", + globalDir: "/pnpm/owner/global", globalRoot: "/pnpm/owner/global/v11", globalBinDir: "/pnpm/owner/bin", + } }), + resolvePnpmActiveLauncherFn: () => "/pnpm/owner/global/v11/node_modules/@bitkyc08/opencodex/bin/ocx.mjs", + integrityFn: () => ({ ok: true as const, integrity: "sha512-testfixturevalue000000000" }), + // Only the environment handed to the update child matters here; failing the command + // ends the job before any restart work, which other suites already cover. + runCommandFn: (_job, _bin, _args, _timeout, env) => { updateEnv = env; return { status: 1, signal: null }; }, + }); + } finally { + // The marker lives in this worker's shared environment; never let it reach a later test. + delete process.env[UPDATE_TEARDOWN_HANDOVER_ENV]; + rmSync(updateJobPath(), { force: true }); + } + expect(updateEnv).toBeDefined(); + expect(updateEnv?.[UPDATE_TEARDOWN_HANDOVER_ENV]).toBe(restart ? "1" : undefined); + }); + } + + test("a starting proxy forgets an inherited marker", () => { + const env: NodeJS.ProcessEnv = { [UPDATE_TEARDOWN_HANDOVER_ENV]: "1", KEEP: "x" }; + forgetUpdateTeardownHandover(env); + expect(env).toEqual({ KEEP: "x" }); + }); +}); + From a3e22148ab4a4bea62a5e97e4e35ab65110f8147 Mon Sep 17 00:00:00 2001 From: Caledvwlch Date: Tue, 27 Oct 2026 03:02:21 +0900 Subject: [PATCH 092/101] fix(codex): renew credit evidence for namespace-bound Direct accounts (#7621) * fix(codex): renew credit evidence for namespace-bound Direct accounts * fix(test): supply required port in native pool fixture * test(codex): isolate ACL setup and cold quota child work * test(codex): complete fetch and routing fixture types --------- Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> --- .../content/docs/guides/codex-integration.md | 2 + .../docs/ko/guides/codex-integration.md | 2 + .../test-layout/layout-credit-renewal.json | 4 + scripts/test-layout/layout.json | 2 +- src/codex/auth-api/pool-mode-gate.ts | 19 +- src/codex/auth-api/pool-quota-probe.ts | 15 + structure/providers/openai-accounts.md | 2 +- structure/providers/openai-tiers.md | 2 +- structure/providers/pool-credit-renewal.md | 29 +- .../codex-direct-credit-renewal.test.ts | 318 ++++++++++++++++++ .../codex-direct-renewal-revocation.test.ts | 212 ++++++++++++ tests/fixtures/test-layout-expected.json | 2 + tests/helpers/compaction-routing-fixtures.ts | 1 + .../direct-credit-renewal-ingress.test.ts | 140 ++++++++ tests/responses/ws-upstream.test.ts | 66 ++-- tests/usage/quota-pool.test.ts | 19 +- tests/usage/quota-reset-observation.test.ts | 8 +- 17 files changed, 793 insertions(+), 50 deletions(-) create mode 100644 scripts/test-layout/layout-credit-renewal.json create mode 100644 tests/codex-integration/codex-direct-credit-renewal.test.ts create mode 100644 tests/codex-integration/codex-direct-renewal-revocation.test.ts create mode 100644 tests/responses/direct-credit-renewal-ingress.test.ts diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 99e8572fde9..eec26ef3049 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -1278,6 +1278,8 @@ An allowed response with primary and secondary windows can omit the optional ter This recovery uses your included plan allowance and does not require enabling **Use credits**. Partial or invalid usage data retains the previous block until recovery is confirmed. +Stored accounts with **Use credits after limit** enabled renew their credit evidence on the existing background cycle once an included usage window is exhausted and the credit observation is at least three minutes old. In **Direct** mode this also covers stored accounts explicitly bound in `codexAccountNamespaces`, such as a sub-account selector. You can keep Direct mode and separate main/sub identities; this does not enable Pool routing or refresh unbound accounts. Paused and reauthentication-required accounts are skipped. Metadata reads do not validate pending accounts or send model requests. Failed reads retain backoff and requests still need fresh spendable credits; ordinary response usage headers do not renew the credit balance timestamp. + ### Cancelling main-account device reauthentication When cancelling main-account device reauthentication, a temporary DELETE or network failure, or a response with an unknown or nonterminal status, keeps the active flow and the cancellation-failure indication so cancellation can be retried. Status polling normally continues so a login that completes can still be detected. If a retryable cancellation failure overlaps a non-2xx GET status response while the flow is `pending` or `committing`, either response order preserves cancellation retry on that same flow, with its last server-provided device code, verification URL, and phase. The GET HTTP failure still stops polling, but cancellation can be retried without starting a second login POST. A terminal `failed` response releases the flow and displays the normalized failure reason; only `succeeded` reports login success. A confirmed `cancelled` response releases the flow so a new device login can be started. A definitive HTTP 404 response with code `unknown_flow` also releases the expired flow ID so a new device login can be started, but does not report a successful login or confirmed cancellation. Late POST, GET, or DELETE responses from an earlier flow cannot change the new flow or report login success for it. diff --git a/docs-site/src/content/docs/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md index fb1c2370de0..ad7c94bb7d5 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -390,6 +390,8 @@ ChatGPT 계정을 추가하거나 재인증할 때 OpenCodex는 일반적으로 **Codex Set → Multi-auth**에서 **Codex Auth** 제목 줄의 **Codex 크레딧** 스위치를 켜면 메인 계정과 풀 계정의 최근 크레딧 잔액이 Week 바로 아래에 표시됩니다. 기본값은 꺼짐이며 `showCodexCredits`로 저장됩니다. 잔액은 로케일에 맞는 숫자로 표시하고, 응답에 따라 무제한 또는 초과 사용 한도 도달 안내를 표시합니다. 전체 크레딧 한도가 제공되지 않으므로 막대는 백분율이 아닌 사용 가능 상태를 나타냅니다. 스위치는 표시만 제어하며 새 로그인에는 해당 계정의 조회 결과가 필요합니다. +**Use credits after limit**을 허용한 저장 계정은 포함 사용량 한도가 소진되고 크레딧 관측이 3분 이상 지나면 기존 백그라운드 주기에서 잔액을 다시 조회합니다. **Direct** 모드에서도 `codexAccountNamespaces`로 명시적으로 연결한 서브 계정 등이 대상입니다. Direct와 메인/서브 계정 분리를 유지하며, Pool 라우팅을 켜거나 연결되지 않은 계정을 조회하지 않습니다. 일시 정지·재인증 필요 계정은 제외합니다. 메타데이터 조회는 검증 대기 계정을 검증하거나 모델 요청을 보내지 않습니다. 조회 실패 시 재시도 대기를 유지하며 요청에는 여전히 최신의 사용 가능한 크레딧 증거가 필요합니다. 일반 응답의 사용량 헤더는 크레딧 잔액 관측 시각을 갱신하지 않습니다. + 별도의 백그라운드 재검증은 기본적으로 꺼져 있습니다. Token Guardian, `openai`의 `proactive` 갱신 정책, `tokenGuardian.codexWarmupEnabled`가 필요하며 등록 검증 대기 계정은 제외합니다. ### 계정이 요청을 처리하지 못하게 된 이유 diff --git a/scripts/test-layout/layout-credit-renewal.json b/scripts/test-layout/layout-credit-renewal.json new file mode 100644 index 00000000000..ccabb377b92 --- /dev/null +++ b/scripts/test-layout/layout-credit-renewal.json @@ -0,0 +1,4 @@ +{ + "codex-direct-credit-renewal.test.ts": "codex-integration", "codex-direct-renewal-revocation.test.ts": "codex-integration", + "direct-credit-renewal-ingress.test.ts": "responses" +} diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 8d1790a74bf..3dac671e9ac 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1,7 +1,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"], + "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"], "explicit": { "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", "service-proxy-env.test.ts": "service", "openai-chat-standalone-bare-block.test.ts": "adapters/openai", "owner-registry-isolation.test.ts": "config", diff --git a/src/codex/auth-api/pool-mode-gate.ts b/src/codex/auth-api/pool-mode-gate.ts index e024473cdb9..7907a1e6c14 100644 --- a/src/codex/auth-api/pool-mode-gate.ts +++ b/src/codex/auth-api/pool-mode-gate.ts @@ -2,7 +2,7 @@ import { codexAccountUsesCreditsAfterLimit, codexUsageLimitResetAt } from "../ac import { nextCodexUsageQueryAt, nextQuotaQueryDelay, pruneRemovedCodexPoolUsageAccounts } from "../quota-query-backoff"; import { CODEX_PRIORITY_FAILBACK_REFRESH_MS } from "../account-priority"; import { codexQuotaHasFreshUsage } from "../quota-observation-freshness"; -import { getCodexAccountCredential, getValidCodexToken, readCodexAccountRecord } from "../account-store"; +import { getCodexAccountCredential, getValidCodexToken, isCodexAccountGenerationLive, readCodexAccountRecord } from "../account-store"; import { getAccountQuota, getMainPolicyQuota, isCompleteCodexQuotaRecoverySnapshot } from "../quota"; import { isCodexAccountPaused } from "../account-pause"; import { hasSpendableCodexCredits } from "../quota-types"; @@ -80,6 +80,13 @@ const MAIN_CREDIT_RENEWAL_AGE_MS = 3 * 60_000; /** Traffic headers renew usage, never the independent credit observation (#7377). */ function poolCreditRenewalDue(config: OcxConfig, accountId: string, now = Date.now()): boolean { + const openai = config.providers[OPENAI_CODEX_PROVIDER_ID]; + if (!openai || openai.disabled === true || !isCanonicalOpenAiForwardProvider(openai)) return false; + const mode = providerCodexAccountMode(OPENAI_CODEX_PROVIDER_ID, openai); + // Direct may renew only explicitly bound stored accounts. Check queued work too: + // credit opt-in alone does not authorize reads for every saved login. + if (mode !== "pool" && (mode !== "direct" + || !Object.values(config.codexAccountNamespaces ?? {}).includes(accountId))) return false; const account = configuredPoolAccount(config, accountId); if (!account || !codexAccountUsesCreditsAfterLimit(config, accountId) || isCodexAccountPaused(config, accountId) || isAccountNeedsReauth(accountId)) return false; @@ -313,7 +320,9 @@ export async function primeCodexPoolQuotas( !openai || openai.disabled === true || !isCanonicalOpenAiForwardProvider(openai) - || providerCodexAccountMode(OPENAI_CODEX_PROVIDER_ID, openai) !== "pool" + || (providerCodexAccountMode(OPENAI_CODEX_PROVIDER_ID, openai) !== "pool" + && !(reason === "credit-renewal" + && providerCodexAccountMode(OPENAI_CODEX_PROVIDER_ID, openai) === "direct")) ) return; if (primeInFlight) return primeInFlight; // Count attempted passes, including failed reads; incoming traffic must not flood WHAM. @@ -384,7 +393,11 @@ export async function primeCodexPoolQuotas( const quota = getAccountQuota(a.id); const bypassCachedQuota = reason === "credit-renewal" || (reason === "priority-failback" && quota !== null && !codexQuotaHasFreshUsage(quota, a.plan, Date.now(), CODEX_PRIORITY_FAILBACK_REFRESH_MS)); - result = await fetchPoolAccountQuota(a.id, bypassCachedQuota, a.plan, getValidPoolTokenForPrime); + result = await fetchPoolAccountQuota(a.id, bypassCachedQuota, a.plan, getValidPoolTokenForPrime, + false, undefined, undefined, false, reason === "credit-renewal" + ? generation => isCodexAccountGenerationLive(a.id, generation) + && poolCreditRenewalDue(getRuntimeConfig(config), a.id) + : undefined); } catch (error) { // Local quota-flight saturation proves no WHAM request existed for this account. // Consume it per item so sibling workers remain inside the shared prime lifetime. diff --git a/src/codex/auth-api/pool-quota-probe.ts b/src/codex/auth-api/pool-quota-probe.ts index 55d30da198c..abd6b2d5fe8 100644 --- a/src/codex/auth-api/pool-quota-probe.ts +++ b/src/codex/auth-api/pool-quota-probe.ts @@ -88,6 +88,8 @@ export function publishQuotaDispatch(sequence: number): void { export interface PoolQuotaProbeEvidence { onDispatch?: (sequence: number) => void; + /** Optional caller authority rechecked after token preparation, including 401 replay. */ + mayDispatch?: (credentialGeneration: number) => boolean; mayPublish?: () => boolean; attempted?: NonNullable; } @@ -109,6 +111,7 @@ export function withQuotaProbeEvidence( export interface PoolQuotaRefreshFlight { state: { + dispatchGuarded?: boolean; dispatchSequence?: number; superseded?: boolean; startCredentialGeneration?: number; @@ -255,6 +258,10 @@ export async function recoverPoolQuotaFrom401(ctx: { // generation and opens a redundant flight. ctx.onCredentialGeneration?.(refreshed.generation); + if (ctx.quotaProbeEvidence.mayDispatch?.(refreshed.generation) === false) { + return { quota: getAccountQuota(accountId), needsReauth: false, credentialGeneration: refreshed.generation, quotaProbeSkipped: true }; + } + const writerGeneration = captureConfigGeneration(); const poolWriter = capturePoolQuotaWriter(accountId, refreshed); const replayRead = await fetchCodexUsage(`pool:${accountId}:${writerGeneration}:${refreshed.generation}`, { @@ -403,6 +410,9 @@ export async function fetchFreshPoolAccountQuota( const poolWriter = capturePoolQuotaWriter(accountId, { accessToken, chatgptAccountId, generation }); requestCredentialGeneration = generation; onCredentialGeneration?.(generation); + if (quotaProbeEvidence.mayDispatch?.(generation) === false) { + return { quota: getAccountQuota(accountId), needsReauth: false, credentialGeneration: generation, quotaProbeSkipped: true }; + } const admission = await fetchCodexUsage( `pool:${accountId}:${writerGeneration}:${generation}${afterDispatchSequence === undefined ? "" : `:post-reset:${afterDispatchSequence}`}`, { headers: { Authorization: `Bearer ${accessToken}`, "ChatGPT-Account-Id": chatgptAccountId }, @@ -496,6 +506,7 @@ export async function fetchPoolAccountQuota( afterDispatchSequence?: number, recoveryProbeNow?: number, explicitRefresh = false, + mayDispatch?: PoolQuotaProbeEvidence["mayDispatch"], ): Promise { const existing = getAccountQuota(accountId); if (afterDispatchSequence === undefined && !forceRefresh && existing && Date.now() - existing.updatedAt < POOL_CACHE_TTL) { @@ -527,6 +538,8 @@ export async function fetchPoolAccountQuota( const generation = flight.state.resolvedCredentialGeneration ?? flight.state.startCredentialGeneration; return !flight.state.superseded + // An ordinary read must not inherit a renewal owner's revocable authority. + && (mayDispatch !== undefined || !flight.state.dispatchGuarded) && (afterDispatchSequence === undefined || (flight.state.dispatchSequence ?? 0) > afterDispatchSequence) && generation !== undefined && isCodexAccountGenerationLive(accountId, generation); }); @@ -543,6 +556,7 @@ export async function fetchPoolAccountQuota( for (const flight of flights ?? []) flight.state.superseded = true; } const state: PoolQuotaRefreshFlight["state"] = { + dispatchGuarded: mayDispatch !== undefined, startCredentialGeneration: record?.generation, validatePending, }; @@ -554,6 +568,7 @@ export async function fetchPoolAccountQuota( getValidToken, { onDispatch: sequence => { state.dispatchSequence = sequence; }, + mayDispatch, mayPublish: () => state.superseded !== true, }, afterDispatchSequence, diff --git a/structure/providers/openai-accounts.md b/structure/providers/openai-accounts.md index 961cee4f8ac..4d4bde2cd02 100644 --- a/structure/providers/openai-accounts.md +++ b/structure/providers/openai-accounts.md @@ -536,7 +536,7 @@ No requests means no new polling. For this reason only, stale observation proof quota-cache freshness after the existing attempt backoff. Main refresh keeps its owned lease and passive intent: cache bypass does not clear an inference reauthentication mark. Other prime reasons retain their existing cache rules except the independently scheduled [pool credit renewal](pool-credit-renewal.md). The split config schema degrades malformed optional values to -false. Exact-account and Direct routes are unchanged. +false. Priority failback leaves exact-account and Direct routes unchanged; explicitly bound Direct stored accounts remain eligible for the separate credit-renewal sweep. ## Main-account policy observations diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 983b001f860..fc17b6c2872 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -334,7 +334,7 @@ identity retries: WHAM success and terminal 401/403 responses never set or clear this mode. Native token preparation also preserves the traffic quarantine. Other callers retain the existing auth behavior. Failed or incomplete observations never renew the credit clock; refreshing usage does not redeem reset credits or validate pending accounts through inference. -Coverage: `tests/codex-integration/main-account-credit-renewal.test.ts`. Pool accounts use the separate [pool credit renewal](pool-credit-renewal.md) contract; their fresh spendable-evidence requirement is identical. +Coverage: `tests/codex-integration/main-account-credit-renewal.test.ts`. Stored accounts, including explicit namespace targets in Direct mode, use the separate [pool credit renewal](pool-credit-renewal.md) contract; their fresh spendable-evidence requirement is identical. Credit parsing, expiry, partial updates and reset-ticket separation are covered in `tests/codex-integration/codex-quota-parser-parity.test.ts`; selection and bulk-pause behavior diff --git a/structure/providers/pool-credit-renewal.md b/structure/providers/pool-credit-renewal.md index ad47b4ebef0..d3215d5b222 100644 --- a/structure/providers/pool-credit-renewal.md +++ b/structure/providers/pool-credit-renewal.md @@ -5,7 +5,10 @@ existing `codex-cooldown-recovery` after-tick hook, using `primeCodexPoolQuotas` `credit-renewal`. No extra timer or inference request is added. Only configured pool accounts opted into `creditCodexAccountIds` with a currently exhausted -included window qualify. Paused, reauthentication-required, below-limit and non-opted-in accounts +included window qualify. In Direct mode, a stored account must also be a current target of +`codexAccountNamespaces`; duplicate aliases still produce one account read. Unqualified Direct +requests do not enable general pool priming. Startup, pre-route and priority-failback reasons +remain Pool-only. Paused, reauthentication-required, below-limit and non-opted-in accounts are skipped. A credit observation younger than three minutes is retained; due or missing evidence uses the existing bounded-concurrency quota reader with cache bypass, because traffic headers can keep aggregate usage fresh while the independent credit clock expires. @@ -14,11 +17,31 @@ The existing per-credential attempt map bounds retries to at least three minutes request admission and generation/configuration publication fences remain owned by the quota reader. Only actual WHAM evidence advances the credit clock. A failed read leaves stale evidence unusable; no credit freshness limit or spending consent is relaxed. Negative evidence remains discoverable. -The eligibility check runs again before each queued account read. The pool-only reason does not -claim, reconcile or read native main; its separate renewal owner is unchanged. +The eligibility check runs again before each queued account read and after asynchronous token +preparation, immediately before WHAM (including a 401 refresh/replay). It checks the current provider, +enabled state, Direct namespace binding, consent, pause/reauth, account membership and renewal age. +The prepared credential generation must still be live; a current rotated generation is accepted. +A skipped first dispatch earns no attempt marker or query backoff. Ordinary metadata readers keep +their own authority and do not join a renewal flight whose dispatch can still be revoked. +This stored-account renewal reason does not +claim, reconcile or read native main; its separate renewal owner is unchanged. In-flight metadata +remains owned by the dispatched credential even if its namespace is rebound. Revoking spending +consent still blocks auth admission after an already dispatched metadata read completes. + +All prime reasons share one in-flight pass. A renewal joining an older startup pass can leave due +credits untouched until the next sweep; it does not start a competing pass. Failures retain the +existing query backoff, and this contract does not promise uninterrupted credit availability. Regression coverage: `tests/codex-integration/codex-quota-prime.test.ts` exercises pre-expiry and expired evidence, admission after a fresh read, the sweep entry, no native-main work, exclusions and failed-read backoff. Tests use fake metadata and private homes, not paid inference. +`tests/codex-integration/codex-direct-credit-renewal.test.ts` adds schema-valid Direct bindings, +the registered sweep callback, real auth admission, coalescing, next-sweep recovery, queued policy +revocation and credential-replacement fencing. `tests/responses/direct-credit-renewal-ingress.test.ts` +checks fresh success, expired local refusal, metadata renewal and terminal success on the same +stored account through the Responses ingress, with fake upstream HTTP only. +`tests/codex-integration/codex-direct-renewal-revocation.test.ts` suspends token preparation to +verify policy revocation, credential deletion/replacement, current-generation acceptance, replay +revocation and ordinary metadata isolation through the real primer and quota reader. > Decision record: [ADR-7377](../decisions/ADR-7377-pool-credit-renewal.md) diff --git a/tests/codex-integration/codex-direct-credit-renewal.test.ts b/tests/codex-integration/codex-direct-credit-renewal.test.ts new file mode 100644 index 00000000000..e1176862faa --- /dev/null +++ b/tests/codex-integration/codex-direct-credit-renewal.test.ts @@ -0,0 +1,318 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { configSchema } from "../../src/config/schema/config-schema"; +import { flushConfigDirHardeningForTests } from "../../src/config/paths"; +import { codexAccountNamespaceForModel } from "../../src/codex/account-namespace-match"; +import { CodexPoolAccountCreditsOffError, materializeCodexUpstreamAuth, resolveCodexAuthContext } from "../../src/codex/auth-context"; +import { isCodexAccountHeldForCredits } from "../../src/codex/account-credit-use"; +import { clearAccountNeedsReauth, markAccountNeedsReauth } from "../../src/codex/account-runtime-state"; +import { readCodexAccountRecord, saveCodexAccountCredential } from "../../src/codex/account-store"; +import { clearCodexQuotaPrimeState, primeCodexPoolQuotas, registerCodexCooldownRecoveryProbeWorker } from "../../src/codex/auth-api/pool-mode-gate"; +import { clearAccountQuota, getAccountQuota, setAccountQuotaFromParsed, updateAccountQuota } from "../../src/codex/quota"; +import { resetQuotaQueryBackoffForTests } from "../../src/codex/quota-query-backoff"; +import { settleBackgroundWork } from "../../src/lib/background-work"; +import * as sweeper from "../../src/lib/state-store-sweeper"; +import type { OcxConfig } from "../../src/types"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +// Real storage, config schema, primer, credential resolution, quota query and parser; +// only the upstream HTTP boundary and wall clock are replaced. No inference is permitted. +describe("Direct explicit-account credit renewal", () => { + let home: string; + let priorHome: string | undefined; + let codexHome: IsolatedCodexHome; + let clock: ReturnType; + let originalFetch: typeof fetch; + let config: OcxConfig; + let now: number; + let calls: string[]; + let unexpected: string[]; + let reply: (accountId: string) => Response | Promise; + let nativeReads: number; + + function saveCredential(id: string, suffix = "") { + saveCodexAccountCredential(id, { + accessToken: `fixture-access-${id}${suffix}`, refreshToken: `fixture-refresh-${id}${suffix}`, + chatgptAccountId: `fixture-account-${id}`, expiresAt: now + 3_600_000, + }); + } + function seed(id: string, age = 360_000, percent = 100) { + config.codexAccounts!.push({ id, isMain: false, plan: "plus", email: `${id}@example.test` }); + saveCredential(id); + setAccountQuotaFromParsed(id, { + weeklyPercent: percent, weeklyResetAt: now + 604_800_000, + credits: { hasCredits: true, balance: 5, allowed: true, observedAt: now - age }, + }); + } + function wham(credits: unknown = { has_credits: true, balance: 7 }, spendControl: unknown = { reached: false }) { + return Response.json({ plan_type: "plus", rate_limit: { + secondary_window: { used_percent: 100, reset_at: (now + 604_800_000) / 1000 }, + }, ...(credits === undefined ? {} : { credits }), spend_control: spendControl }); + } + function held(id = "stored-a") { + return isCodexAccountHeldForCredits(config, id, getAccountQuota(id), "plus", now); + } + function prime(reason = "credit-renewal") { + const noMain = () => { nativeReads++; throw new Error("renewal must not access native main"); }; + return primeCodexPoolQuotas(config, reason, { + reconcileMainAccount: noMain, readMainTokens: noMain, fetchMainInfo: noMain, + }); + } + function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; + } + function registeredTick() { + let tick!: () => void; + const registration = spyOn(sweeper, "registerStateSweepAfterTick").mockImplementation(entry => { + expect(entry.name).toBe("codex-cooldown-recovery"); tick = entry.afterTick; + return () => {}; + }); + try { registerCodexCooldownRecoveryProbeWorker(config); } + finally { registration.mockRestore(); } + expect(tick).toBeFunction(); + return tick; + } + + beforeEach(() => { + priorHome = process.env.OPENCODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-direct-credit-")); + process.env.OPENCODEX_HOME = home; + codexHome = installIsolatedCodexHome("ocx-direct-credit-native-"); + writeFileSync(join(codexHome.path, "auth.json"), JSON.stringify({ + tokens: { access_token: "fixture-native-main", account_id: "fixture-native-account" }, + })); + now = Date.now(); + clock = spyOn(Date, "now").mockReturnValue(now); + clearCodexQuotaPrimeState(); clearAccountQuota(); resetQuotaQueryBackoffForTests(); + calls = []; unexpected = []; nativeReads = 0; + config = { + port: 10100, defaultProvider: "openai", providers: { + openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", codexAccountMode: "direct" }, + }, + codexAccounts: [], codexAccountNamespaces: { subAlias: "stored-a", mainAlias: "@main" }, + creditCodexAccountIds: ["stored-a", "stored-b"], + } as OcxConfig; + seed("stored-a"); seed("stored-b"); + // Prevent the old source-slice fixture's namespace/account-id collision from recurring. + expect(configSchema.safeParse(config).success).toBe(true); + originalFetch = globalThis.fetch; + reply = () => wham(); + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + const headers = new Headers(init?.headers); + const account = headers.get("chatgpt-account-id") ?? ""; + const id = account.replace(/^fixture-account-/, ""); + if (url !== "https://chatgpt.com/backend-api/wham/usage" || (init?.method ?? "GET") !== "GET" + || !account.startsWith("fixture-account-")) { + unexpected.push("non-fixture metadata request"); + throw new Error("only fixture WHAM requests are permitted"); + } + expect(headers.get("authorization")).toBe(`Bearer ${readCodexAccountRecord(id)!.credential!.accessToken}`); + calls.push(id); + return reply(id); + }) as typeof fetch; + }); + + afterEach(async () => { + const drained = await settleBackgroundWork({ timeoutMs: 1000 }); + globalThis.fetch = originalFetch; + clock.mockRestore(); + clearAccountNeedsReauth("stored-a"); + clearCodexQuotaPrimeState(); clearAccountQuota(); resetQuotaQueryBackoffForTests(); + await flushConfigDirHardeningForTests(); + if (priorHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = priorHome; + codexHome.restoreEnvironment(); + if (drained.settled) { removeTreeWithRetry(home); codexHome.removeAfterDrain(); } + expect(drained.settled).toBe(true); + expect(unexpected).toEqual([]); + expect(nativeReads).toBe(0); + }); + + test.each([180_000, 360_000])("renews at %d ms through real WHAM parsing despite fresh usage", async age => { + setAccountQuotaFromParsed("stored-a", { credits: { + hasCredits: true, balance: 5, allowed: true, observedAt: now - age, + } }); + updateAccountQuota("stored-a", 100); + expect(getAccountQuota("stored-a")!.updatedAt).toBe(now); + expect(getAccountQuota("stored-a")!.credits!.observedAt).toBe(now - age); + expect(held()).toBe(age > 300_000); + expect(codexAccountNamespaceForModel(config.codexAccountNamespaces, "subAlias/gpt-5.5")).toBe("subAlias"); + const before = structuredClone(config); + await prime(); + expect(calls).toEqual(["stored-a"]); + expect(getAccountQuota("stored-a")!.credits).toMatchObject({ balance: 7, observedAt: now, allowed: true }); + expect(held()).toBe(false); + expect(getAccountQuota("stored-b")!.credits!.observedAt).toBe(now - 360_000); + expect(config).toEqual(before); + }); + + test("deduplicates aliases and coalesces concurrent renewals", async () => { + config.codexAccountNamespaces!.anotherAlias = "stored-a"; + expect(configSchema.safeParse(config).success).toBe(true); + const entered = deferred(), release = deferred(); + reply = async () => { entered.resolve(); await release.promise; return wham(); }; + const first = prime(), second = prime(); + try { await Promise.race([entered.promise, first]); expect(calls).toEqual(["stored-a"]); } + finally { release.resolve(); await Promise.all([first, second]); } + await prime(); + expect(calls).toEqual(["stored-a"]); + expect(held()).toBe(false); + }); + + test("the registered sweep releases real Direct auth only for the namespace's stored credential", async () => { + const namespace = codexAccountNamespaceForModel(config.codexAccountNamespaces, "subAlias/gpt-5.5")!; + const caller = new Headers({ authorization: "Bearer fixture-caller-main", "chatgpt-account-id": "fixture-caller-account" }); + const resolve = () => resolveCodexAuthContext(caller, config, "direct", { + accountId: config.codexAccountNamespaces![namespace], modelId: "gpt-5.5", + }); + await expect(resolve()).rejects.toBeInstanceOf(CodexPoolAccountCreditsOffError); + expect(calls).toEqual([]); + registeredTick()(); + // Join the work started by the actual registered callback, rather than sleeping for a timer. + expect((await settleBackgroundWork({ timeoutMs: 1000 })).settled).toBe(true); + expect(calls).toEqual(["stored-a"]); + const context = await resolve(); + expect(context.kind).toBe("pool"); expect(context.accountId).toBe("stored-a"); + const auth = materializeCodexUpstreamAuth(caller, context); + expect(auth.get("authorization")).toBe("Bearer fixture-access-stored-a"); + expect(auth.get("chatgpt-account-id")).toBe("fixture-account-stored-a"); + expect(caller.get("authorization")).toBe("Bearer fixture-caller-main"); + }); + + test("renewal joining a pending startup prime is retried by the next sweep", async () => { + config.providers.openai!.codexAccountMode = "pool"; + clock.mockReturnValue(now + 360_000); + // Startup has only B to refresh: A's usage is fresh but its credit evidence is not. + updateAccountQuota("stored-a", 100); + const entered = deferred(), release = deferred(); + reply = async () => { entered.resolve(); await release.promise; return wham(); }; + const running = primeCodexPoolQuotas(config, "startup", { + reconcileMainAccount: () => false, readMainTokens: () => null, + }); + const tick = registeredTick(); + try { + await Promise.race([entered.promise, running]); expect(calls).toEqual(["stored-b"]); + config.providers.openai!.codexAccountMode = "direct"; + tick(); expect(calls).toEqual(["stored-b"]); + } finally { release.resolve(); await running; } + expect(getAccountQuota("stored-a")!.credits!.observedAt).toBe(now - 360_000); + reply = () => wham(); tick(); + expect((await settleBackgroundWork({ timeoutMs: 1000 })).settled).toBe(true); + expect(calls).toEqual(["stored-b", "stored-a"]); + }); + + test.each(["binding", "consent", "pause", "reauth", "provider", "removal"])("rechecks queued %s revocation", async revoked => { + const ids = ["stored-a", "stored-b", "stored-c", "stored-d", "queued-account"]; + for (const id of ids.slice(2)) seed(id); + config.creditCodexAccountIds = ids; + config.codexAccountNamespaces = Object.fromEntries(ids.map((id, i) => [`alias${i}`, id])); + expect(configSchema.safeParse(config).success).toBe(true); + const entered = deferred(), release = deferred(); + reply = async () => { if (calls.length === 4) entered.resolve(); await release.promise; return wham(); }; + const running = prime(); + try { + await Promise.race([entered.promise, running]); + expect(calls).toHaveLength(4); + if (revoked === "binding") delete config.codexAccountNamespaces.alias4; + if (revoked === "consent") config.creditCodexAccountIds = ids.slice(0, 4); + if (revoked === "pause") config.pausedCodexAccountIds = ["queued-account"]; + if (revoked === "reauth") markAccountNeedsReauth("queued-account"); + if (revoked === "provider") config.providers.openai!.disabled = true; + if (revoked === "removal") config.codexAccounts = config.codexAccounts!.filter(a => a.id !== "queued-account"); + } finally { release.resolve(); await running; clearAccountNeedsReauth("queued-account"); } + expect(calls).toEqual(ids.slice(0, 4)); + }); + + test("a replaced credential rejects late credit publication and does not inherit attempt backoff", async () => { + const entered = deferred(), release = deferred(); + reply = async () => { entered.resolve(); await release.promise; return wham(); }; + const running = prime(); + try { + await Promise.race([entered.promise, running]); expect(calls).toEqual(["stored-a"]); + saveCredential("stored-a", "-replacement"); + setAccountQuotaFromParsed("stored-a", { credits: { + hasCredits: true, balance: 9, allowed: true, observedAt: now - 360_000, + } }); + } finally { release.resolve(); await running; } + expect(getAccountQuota("stored-a")!.credits).toMatchObject({ balance: 9, observedAt: now - 360_000 }); + expect(held()).toBe(true); + reply = () => wham(); + await prime(); + expect(calls).toEqual(["stored-a", "stored-a"]); + expect(held()).toBe(false); + }); + + test("in-flight rebinding cannot publish the old account's credits onto its new target or authorize opted-out spend", async () => { + const entered = deferred(), release = deferred(); + reply = async () => { entered.resolve(); await release.promise; return wham(); }; + const running = prime(); + try { + await Promise.race([entered.promise, running]); expect(calls).toEqual(["stored-a"]); + config.codexAccountNamespaces!.subAlias = "stored-b"; + config.creditCodexAccountIds = ["stored-b"]; + } finally { release.resolve(); await running; } + expect(getAccountQuota("stored-b")!.credits!.observedAt).toBe(now - 360_000); + expect(held()).toBe(true); + reply = () => wham(); await prime(); + expect(calls).toEqual(["stored-a", "stored-b"]); + }); + + test.each([503, 429])("failed WHAM %d is paced without renewing the credit clock", async status => { + reply = () => new Response(null, { status, headers: { "Retry-After": "600" } }); + await prime(); await prime(); + expect(calls).toEqual(["stored-a"]); + expect(getAccountQuota("stored-a")!.credits!.observedAt).toBe(now - 360_000); + clock.mockReturnValue(now + 180_000); + await prime(); expect(calls).toEqual(["stored-a"]); // query reader's backoff still wins + expect(held()).toBe(true); + }); + + test.each([ + { has_credits: true, balance: 0 }, { has_credits: false, balance: 7 }, + { has_credits: true, balance: 7, overage_limit_reached: true }, null, "invalid", + ])("negative WHAM credits %j never release admission", async credits => { + reply = () => wham(credits); + await prime(); expect(calls).toEqual(["stored-a"]); expect(held()).toBe(true); + }); + test("spend-control denial overrides positive funds", async () => { + reply = () => wham({ has_credits: true, balance: 7 }, { reached: true }); + await prime(); expect(calls).toEqual(["stored-a"]); expect(held()).toBe(true); + }); + test("passive renewal does not validate a pending credential or send inference", async () => { + saveCodexAccountCredential("stored-a", readCodexAccountRecord("stored-a")!.credential!, { validationPending: true }); + await prime(); expect(calls).toEqual(["stored-a"]); + expect(readCodexAccountRecord("stored-a")!.codexValidationPending).toBe(true); + }); + test("Pool keeps renewal of all eligible stored accounts", async () => { + config.providers.openai!.codexAccountMode = "pool"; + await prime(); expect(calls).toEqual(["stored-a", "stored-b"]); + }); + test.each(["startup", "pre-route", "priority-failback"])("Direct %s still does not prime", async reason => { + await prime(reason); expect(calls).toEqual([]); + }); + test.each(["plain", "disabled", "api", "below-limit", "young", "paused", "opt-out", "reauth"])("skips %s", async condition => { + if (condition === "plain") config.codexAccountNamespaces = {}; + if (condition === "disabled") config.providers.openai!.disabled = true; + if (condition === "api") { + config.providers = { "openai-apikey": { adapter: "openai-responses", + baseUrl: "https://api.openai.com/v1", authMode: "key", apiKey: "fixture-api-key" } }; + config.defaultProvider = "openai-apikey"; + expect(configSchema.safeParse(config).success).toBe(true); + } + if (condition === "below-limit") updateAccountQuota("stored-a", 2); + if (condition === "young") setAccountQuotaFromParsed("stored-a", { credits: { + hasCredits: true, balance: 5, allowed: true, observedAt: now - 179_999, + } }); + if (condition === "paused") config.pausedCodexAccountIds = ["stored-a"]; + if (condition === "opt-out") config.creditCodexAccountIds = []; + if (condition === "reauth") markAccountNeedsReauth("stored-a"); + await prime(); expect(calls).toEqual([]); + }); +}); diff --git a/tests/codex-integration/codex-direct-renewal-revocation.test.ts b/tests/codex-integration/codex-direct-renewal-revocation.test.ts new file mode 100644 index 00000000000..6f3dec6284d --- /dev/null +++ b/tests/codex-integration/codex-direct-renewal-revocation.test.ts @@ -0,0 +1,212 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { flushConfigDirHardeningForTests } from "../../src/config/paths"; +import { clearAccountNeedsReauth, markAccountNeedsReauth } from "../../src/codex/account-runtime-state"; +import { getValidCodexToken, readCodexAccountRecord, removeCodexAccountCredential, saveCodexAccountCredential } from "../../src/codex/account-store"; +import { clearCodexQuotaPrimeState, primeCodexPoolQuotas, setCodexPoolQuotaTokenResolverForTests } from "../../src/codex/auth-api/pool-mode-gate"; +import { fetchPoolAccountQuota } from "../../src/codex/auth-api/pool-quota-probe"; +import { clearAccountQuota, getAccountQuota, setAccountQuotaFromParsed } from "../../src/codex/quota"; +import { resetQuotaQueryBackoffForTests } from "../../src/codex/quota-query-backoff"; +import { settleBackgroundWork } from "../../src/lib/background-work"; +import type { OcxConfig } from "../../src/types"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +function deferred() { + let resolve!: () => void; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} + +// Real primer, store, WHAM reader and parser. Only token timing and external HTTP +// are controlled; every unexpected HTTP request fails closed inside the fixture. +describe("credit renewal rechecks authority after token preparation", () => { + const id = "renewal-fixture"; + let home: string, priorHome: string | undefined, nativeHome: IsolatedCodexHome; + let config: OcxConfig, observedAt: number; + let originalFetch: typeof fetch; + let calls: { account: string | null; bearer: string | null }[]; + let unexpected: string[]; + let reply: () => Response | Promise; + let refreshReply: () => Response | Promise; + + function save(suffix = "") { + saveCodexAccountCredential(id, { + accessToken: `fixture-access${suffix}`, refreshToken: `fixture-refresh${suffix}`, + chatgptAccountId: "fixture-workspace", expiresAt: Date.now() + 3_600_000, + }); + } + function wham() { + return Response.json({ plan_type: "plus", rate_limit: { + secondary_window: { used_percent: 100, reset_at: (Date.now() + 604_800_000) / 1000 }, + }, credits: { has_credits: true, balance: 7 } }); + } + function revoke(kind: string) { + if (kind === "binding") config.codexAccountNamespaces = {}; + if (kind === "rebind") config.codexAccountNamespaces = { subAlias: "other-fixture" }; + if (kind === "consent") config.creditCodexAccountIds = []; + if (kind === "pause") config.pausedCodexAccountIds = [id]; + if (kind === "provider") config.providers.openai!.disabled = true; + if (kind === "provider-replacement") config.providers.openai!.baseUrl = "https://example.test/v1"; + if (kind === "reauth") markAccountNeedsReauth(id); + if (kind === "removal") config.codexAccounts = []; + if (kind === "replacement") save("-replacement"); + if (kind === "deletion") removeCodexAccountCredential(id); + } + function prime() { return primeCodexPoolQuotas(config, "credit-renewal"); } + function delayedToken() { + const entered = deferred(), release = deferred(); + const resolver: typeof getValidCodexToken = async accountId => { + const token = await getValidCodexToken(accountId); + entered.resolve(); + await release.promise; + return token; + }; + return { entered, release, resolver }; + } + + beforeEach(() => { + priorHome = process.env.OPENCODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-renewal-revocation-")); + process.env.OPENCODEX_HOME = home; + nativeHome = installIsolatedCodexHome("ocx-renewal-revocation-native-"); + clearCodexQuotaPrimeState(); clearAccountQuota(); resetQuotaQueryBackoffForTests(); + config = { + port: 10100, defaultProvider: "openai", providers: { + openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", codexAccountMode: "direct" }, + }, + codexAccounts: [{ id, isMain: false, plan: "plus", email: "fixture@example.test" }], + codexAccountNamespaces: { subAlias: id }, creditCodexAccountIds: [id], + } as OcxConfig; + save(); observedAt = Date.now() - 360_000; + setAccountQuotaFromParsed(id, { weeklyPercent: 100, weeklyResetAt: Date.now() + 604_800_000, + credits: { hasCredits: true, balance: 5, observedAt } }); + calls = []; unexpected = []; reply = wham; + refreshReply = () => { throw new Error("unexpected fixture token refresh"); }; + originalFetch = globalThis.fetch; + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url === "https://auth.openai.com/oauth/token") return refreshReply(); + if (url !== "https://chatgpt.com/backend-api/wham/usage" || (init?.method ?? "GET") !== "GET") { + unexpected.push(url); throw new Error("non-fixture HTTP is forbidden"); + } + const headers = new Headers(init?.headers); + calls.push({ account: headers.get("chatgpt-account-id"), bearer: headers.get("authorization") }); + return reply(); + }) as typeof fetch; + }); + + afterEach(async () => { + const drained = await settleBackgroundWork({ timeoutMs: 1000 }); + clearAccountNeedsReauth(id); + clearCodexQuotaPrimeState(); clearAccountQuota(); resetQuotaQueryBackoffForTests(); + await flushConfigDirHardeningForTests(); + globalThis.fetch = originalFetch; + if (priorHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = priorHome; + nativeHome.restoreEnvironment(); + if (drained.settled) { removeTreeWithRetry(home); nativeHome.removeAfterDrain(); } + expect(drained.settled).toBe(true); expect(unexpected).toEqual([]); + }); + + test.each(["binding", "rebind", "consent", "pause", "provider", "provider-replacement", "reauth", "removal", "replacement", "deletion"])( + "does not dispatch a prepared token after %s", async kind => { + const delay = delayedToken(); + const restore = setCodexPoolQuotaTokenResolverForTests(delay.resolver); + const running = prime(); + try { + await Promise.race([delay.entered.promise, running]); + expect(calls).toEqual([]); + revoke(kind); + } finally { delay.release.resolve(); await running; restore(); } + expect(calls).toEqual([]); + expect(getAccountQuota(id)?.credits?.observedAt).not.toBeGreaterThan(observedAt); + }, + ); + + test.each(["consent", "pause", "provider", "removal"])("Pool renewal also rechecks %s after token preparation", async kind => { + config.providers.openai!.codexAccountMode = "pool"; + const delay = delayedToken(); + const restore = setCodexPoolQuotaTokenResolverForTests(delay.resolver); + const running = prime(); + try { + await Promise.race([delay.entered.promise, running]); expect(calls).toEqual([]); revoke(kind); + } finally { delay.release.resolve(); await running; restore(); } + expect(calls).toEqual([]); + }); + + test("a skipped read does not consume the next eligible renewal's attempt budget", async () => { + const delay = delayedToken(); + const restore = setCodexPoolQuotaTokenResolverForTests(delay.resolver); + const running = prime(); + try { + await Promise.race([delay.entered.promise, running]); expect(calls).toEqual([]); revoke("binding"); + } finally { delay.release.resolve(); await running; restore(); } + expect(calls).toEqual([]); + config.codexAccountNamespaces = { subAlias: id }; + await prime(); + expect(calls).toEqual([{ account: "fixture-workspace", bearer: "Bearer fixture-access" }]); + expect(getAccountQuota(id)?.credits?.balance).toBe(7); + }); + + test("accepts the current generation returned by token preparation", async () => { + const entered = deferred(), release = deferred(); + const before = readCodexAccountRecord(id)!.generation; + const restore = setCodexPoolQuotaTokenResolverForTests(async accountId => { + entered.resolve(); await release.promise; + return getValidCodexToken(accountId); + }); + const running = prime(); + try { + await Promise.race([entered.promise, running]); expect(calls).toEqual([]); save("-rotated"); + } finally { release.resolve(); await running; restore(); } + expect(readCodexAccountRecord(id)!.generation).toBeGreaterThan(before); + expect(calls).toEqual([{ account: "fixture-workspace", bearer: "Bearer fixture-access-rotated" }]); + expect(getAccountQuota(id)?.credits?.balance).toBe(7); + }); + + test.each(["binding", "consent", "pause", "provider"])("401 replay rechecks %s after real token refresh", async kind => { + const entered = deferred(), release = deferred(); + const before = readCodexAccountRecord(id)!.generation; + reply = () => calls.length === 1 ? Response.json({ error: { code: "transient_edge_rejection" } }, { status: 401 }) : wham(); + refreshReply = async () => { + entered.resolve(); await release.promise; + return Response.json({ access_token: "fixture-rotated", refresh_token: "fixture-refresh-rotated", expires_in: 3600 }); + }; + const running = prime(); + try { + await Promise.race([entered.promise, running]); expect(calls).toHaveLength(1); revoke(kind); + } finally { release.resolve(); await running; } + expect(readCodexAccountRecord(id)!.generation).toBeGreaterThan(before); + expect(calls).toEqual([{ account: "fixture-workspace", bearer: "Bearer fixture-access" }]); + expect(getAccountQuota(id)?.credits?.observedAt).toBe(observedAt); + }); + + test("ordinary metadata reads keep their independent authority", async () => { + const delay = delayedToken(); + const running = fetchPoolAccountQuota(id, true, "plus", delay.resolver); + try { + await Promise.race([delay.entered.promise, running]); expect(calls).toEqual([]); + for (const kind of ["binding", "consent", "pause", "provider"]) revoke(kind); + } finally { delay.release.resolve(); await running; } + expect(calls).toEqual([{ account: "fixture-workspace", bearer: "Bearer fixture-access" }]); + expect(getAccountQuota(id)?.credits?.balance).toBe(7); + }); + + test("ordinary metadata reads do not inherit a pending renewal's revocable authority", async () => { + const delay = delayedToken(); + const restore = setCodexPoolQuotaTokenResolverForTests(delay.resolver); + const running = prime(); + let ordinary: ReturnType | undefined; + try { + await Promise.race([delay.entered.promise, running]); expect(calls).toEqual([]); + ordinary = fetchPoolAccountQuota(id, true, "plus"); + revoke("consent"); + } finally { delay.release.resolve(); await Promise.all([running, ordinary]); restore(); } + expect(calls).toEqual([{ account: "fixture-workspace", bearer: "Bearer fixture-access" }]); + expect((await ordinary)?.freshQuota?.credits?.balance).toBe(7); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index f8c40421f3d..cead7ca3353 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -178,6 +178,8 @@ "codex-credits-settings.test.ts": "codex-integration", "codex-credits-probes.test.ts": "codex-integration", "codex-credits-after-limit.test.ts": "codex-integration", "codex-pool-credit-policy.test.ts": "codex-integration", + "codex-direct-credit-renewal.test.ts": "codex-integration", "codex-direct-renewal-revocation.test.ts": "codex-integration", + "direct-credit-renewal-ingress.test.ts": "responses", "codex-sidecar-credit-policy.test.ts": "codex-integration", "service-runtime-preflight.test.ts": "service", "service-desktop-runtime-preflight.test.ts": "service", "codex-credits-after-limit-main.test.ts": "codex-integration", "service-desktop-login-registration.test.ts": "service", "service-desktop-startup-health.test.ts": "service", "service-desktop-startup-linux.test.ts": "service", diff --git a/tests/helpers/compaction-routing-fixtures.ts b/tests/helpers/compaction-routing-fixtures.ts index 3ae7cef893a..616b9350dc2 100644 --- a/tests/helpers/compaction-routing-fixtures.ts +++ b/tests/helpers/compaction-routing-fixtures.ts @@ -61,6 +61,7 @@ export function keyProviderConfig(overrides: Partial = {}): O export function nativePoolConfig(): OcxConfig { return { + port: 0, defaultProvider: "openai", activeCodexAccountId: "pool-a", providers: { diff --git a/tests/responses/direct-credit-renewal-ingress.test.ts b/tests/responses/direct-credit-renewal-ingress.test.ts new file mode 100644 index 00000000000..c722ca9367b --- /dev/null +++ b/tests/responses/direct-credit-renewal-ingress.test.ts @@ -0,0 +1,140 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { configSchema } from "../../src/config/schema/config-schema"; +import { saveCodexAccountCredential } from "../../src/codex/account-store"; +import { clearCodexQuotaPrimeState, primeCodexPoolQuotas } from "../../src/codex/auth-api/pool-mode-gate"; +import { clearAccountQuota, getAccountQuota, setAccountQuotaFromParsed } from "../../src/codex/quota"; +import { resetQuotaQueryBackoffForTests } from "../../src/codex/quota-query-backoff"; +import { clearCodexUpstreamHealth, clearThreadAccountMap } from "../../src/codex/routing"; +import { clearPoolRotationState } from "../../src/codex/pool-rotation"; +import { handleResponses } from "../../src/server/responses"; +import { codexWsPool } from "../../src/server/responses/codex-ws-pool"; +import { settleBackgroundWork } from "../../src/lib/background-work"; +import type { OcxConfig } from "../../src/types"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { completedPayload, drainCompactionResponseState, installCompactionRoutingAclFixture, sseResponse } from "../helpers/compaction-routing-fixtures"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +// Real ingress, namespace resolution, auth admission, WHAM parser and terminal-response +// delivery. All upstream I/O uses synthetic credentials and an allowlisted fake transport. +installCompactionRoutingAclFixture(); +let previousHome: string | undefined; +let home: string; +let codexHome: IsolatedCodexHome; +let releaseSpendHome: () => void; +let clock: ReturnType; +let now: number; +let config: OcxConfig; +let realFetch: typeof fetch; +let realWebSocket: typeof WebSocket; +let metadataAccounts: string[]; +let responseAccounts: string[]; +let unexpected: string[]; + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-direct-credit-ingress-")); + process.env.OPENCODEX_HOME = home; + codexHome = installIsolatedCodexHome("ocx-credit-ingress-native-"); + releaseSpendHome = acquireOwnedSpendHome(); + clearAccountQuota(); clearCodexQuotaPrimeState(); resetQuotaQueryBackoffForTests(); + clearThreadAccountMap(); clearCodexUpstreamHealth(); clearPoolRotationState(); + now = Date.now(); + clock = spyOn(Date, "now").mockImplementation(() => now); + config = { + port: 10100, defaultProvider: "openai", providers: { openai: { + adapter: "openai-responses", authMode: "forward", codexAccountMode: "direct", + baseUrl: "https://chatgpt.com/backend-api/codex", + } }, + codexAccounts: [ + { id: "stored-a", email: "a@example.test", isMain: false, plan: "plus" }, + { id: "stored-b", email: "b@example.test", isMain: false, plan: "plus" }, + ], + codexAccountNamespaces: { subAlias: "stored-a", mainAlias: "@main" }, + creditCodexAccountIds: ["stored-a", "stored-b"], + } as OcxConfig; + expect(configSchema.safeParse(config).success).toBe(true); + for (const id of ["stored-a", "stored-b"]) { + saveCodexAccountCredential(id, { + accessToken: `fixture-access-${id}`, refreshToken: `fixture-refresh-${id}`, + expiresAt: now + 3_600_000, chatgptAccountId: `fixture-account-${id}`, + }); + setAccountQuotaFromParsed(id, { weeklyPercent: 100, weeklyResetAt: now + 604_800_000, + credits: { hasCredits: true, balance: 5, allowed: true, observedAt: now } }); + } + metadataAccounts = []; responseAccounts = []; unexpected = []; + realFetch = globalThis.fetch; realWebSocket = globalThis.WebSocket; + globalThis.WebSocket = class { + constructor() { unexpected.push("WebSocket"); throw new Error("buffered fixture must use HTTP"); } + } as unknown as typeof WebSocket; + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + const headers = new Headers(init?.headers); + const account = headers.get("chatgpt-account-id") ?? ""; + expect(account).toBe("fixture-account-stored-a"); + expect(headers.get("authorization")).toBe("Bearer fixture-access-stored-a"); + if (url === "https://chatgpt.com/backend-api/wham/usage" && (init?.method ?? "GET") === "GET") { + metadataAccounts.push(account); + return Response.json({ plan_type: "plus", rate_limit: { secondary_window: { + used_percent: 100, reset_at: (now + 604_800_000) / 1000, + } }, credits: { has_credits: true, balance: 5 }, spend_control: { reached: false } }); + } + if (url === "https://chatgpt.com/backend-api/codex/responses" && init?.method === "POST") { + responseAccounts.push(account); + return sseResponse([{ type: "response.completed", response: completedPayload("fixture completed") }]); + } + unexpected.push("unexpected HTTP destination"); + throw new Error("only the fixture metadata and Responses endpoints are permitted"); + }) as typeof fetch; +}); + +afterEach(async () => { + await drainCompactionResponseState(); + const drained = await settleBackgroundWork({ timeoutMs: 2000 }); + codexWsPool.dispose(); releaseSpendHome(); + globalThis.fetch = realFetch; globalThis.WebSocket = realWebSocket; clock.mockRestore(); + clearAccountQuota(); clearCodexQuotaPrimeState(); resetQuotaQueryBackoffForTests(); + clearThreadAccountMap(); clearCodexUpstreamHealth(); clearPoolRotationState(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + codexHome.restoreEnvironment(); + if (drained.settled) { removeTreeWithRetry(home); codexHome.removeAfterDrain(); } + expect(drained.settled).toBe(true); + expect(unexpected).toEqual([]); +}); + +async function send() { + const request = new Request("http://localhost/v1/responses", { method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "subAlias/gpt-5.5", input: "fixture", stream: false, store: false }), + }); + const response = await handleResponses(request, config, { model: "", provider: "" }); + return { status: response.status, body: await response.json() }; +} + +test("same Direct namespace recovers after five-minute credit expiry without fallback or external refresh", async () => { + const original = structuredClone(config); + const first = await send(); + expect(first.status).toBe(200); + expect(first.body.status).toBe("completed"); + now += 360_001; + const stale = await send(); + expect(stale.status).toBe(429); + expect(responseAccounts).toHaveLength(1); + expect(metadataAccounts).toEqual([]); + const noMain = () => { throw new Error("stored-account renewal must never access main"); }; + await primeCodexPoolQuotas(config, "credit-renewal", { + reconcileMainAccount: noMain, readMainTokens: noMain, fetchMainInfo: noMain, + }); + const recovered = await send(); + expect(recovered.status).toBe(200); + expect(recovered.body.status).toBe("completed"); + expect(recovered.body.output[0].content[0].text).toBe("fixture completed"); + expect(metadataAccounts).toEqual(["fixture-account-stored-a"]); + expect(responseAccounts).toEqual(["fixture-account-stored-a", "fixture-account-stored-a"]); + expect(getAccountQuota("stored-b")!.credits!.observedAt).toBe(now - 360_001); + expect(config).toEqual(original); +}); diff --git a/tests/responses/ws-upstream.test.ts b/tests/responses/ws-upstream.test.ts index 9d587853a5f..1522e4bb192 100644 --- a/tests/responses/ws-upstream.test.ts +++ b/tests/responses/ws-upstream.test.ts @@ -33,6 +33,8 @@ import type { OcxProviderConfig } from "../../src/types"; import type { OcxConfig } from "../../src/types"; import { BOUNDED_WS_RUNTIME, codexWsUpstreamFetch, shouldUseCodexWsUpstream, streamingInit } from "../helpers/ws-upstream-fixtures"; import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { installCompactionRoutingAclFixture as installRoutingAclFixture } from "../helpers/compaction-routing-fixtures"; +installRoutingAclFixture(); const CODEX_URL = "https://chatgpt.com/backend-api/codex/responses"; @@ -82,7 +84,7 @@ describe("shouldUseCodexWsUpstream", () => { const response = await rawCodexWsUpstreamFetch( CODEX_URL, streamingInit(), - (async () => sentinel) as typeof fetch, + Object.assign(async () => sentinel, { preconnect: RealFetch.preconnect }), "1.3.14", ); expect(response).toBe(sentinel); @@ -92,7 +94,7 @@ describe("shouldUseCodexWsUpstream", () => { const canaryResponse = await rawCodexWsUpstreamFetch( CODEX_URL, streamingInit(), - (async () => canarySentinel) as typeof fetch, + Object.assign(async () => canarySentinel, { preconnect: RealFetch.preconnect }), { version: "1.4.0", versionWithSha: "v1.4.0-canary.1 (0123abcd)" }, ); expect(canaryResponse).toBe(canarySentinel); @@ -242,11 +244,11 @@ describe("providerFetch routing", () => { test("a canary runtime identity cannot open the WS transport", async () => { const sentinel = new Response("base"); let baseCalls = 0; - const provider = { - fetch: (async () => { + const provider = { adapter: "openai-responses", baseUrl: CODEX_URL, + fetch: Object.assign(async () => { baseCalls += 1; return sentinel; - }) as typeof fetch, + }, { preconnect: RealFetch.preconnect }), } as OcxProviderConfig; const wrapped = providerFetch(provider, { version: "1.4.0", @@ -564,13 +566,13 @@ describe("handleResponses Codex WS relay selection", () => { test("an HTTP fallback remains on the configured legacy tee path", async () => { installFake(ws => ws.close()); - globalThis.fetch = (async () => new Response( + globalThis.fetch = Object.assign(async () => new Response( `event: response.completed\ndata: ${JSON.stringify({ type: "response.completed", response: { id: "r-http", status: "completed", output: [] }, })}\n\n`, { status: 200, headers: { "content-type": "text/event-stream" } }, - )) as typeof fetch; + ), { preconnect: RealFetch.preconnect }); takeSpendHome(); const response = await handleResponses(request(), forwardConfig(), { model: "", provider: "" }, { @@ -593,7 +595,7 @@ describe("handleResponses Codex WS relay selection", () => { } }); - const logCtx = { model: "", provider: "" }; + const logCtx: Parameters[2] = { model: "", provider: "" }; takeSpendHome(); const response = await handleResponses(request(), forwardConfig(), logCtx, { codexWsRuntimeIdentity: BOUNDED_WS_RUNTIME, @@ -613,7 +615,7 @@ describe("handleResponses Codex WS relay selection", () => { ws.emit("close", { code: 1006 }); }); - const logCtx = { model: "", provider: "" }; + const logCtx: Parameters[2] = { model: "", provider: "" }; takeSpendHome(); const response = await handleResponses(request(), forwardConfig(), logCtx, { codexWsRuntimeIdentity: BOUNDED_WS_RUNTIME, @@ -631,13 +633,13 @@ describe("handleResponses Codex WS relay selection", () => { test.skipIf(bunSupportsBoundedCodexWsRelay())( "an older runtime stays on HTTP SSE without opening a WebSocket", async () => { - globalThis.fetch = (async () => new Response( + globalThis.fetch = Object.assign(async () => new Response( `event: response.completed\ndata: ${JSON.stringify({ type: "response.completed", response: { id: "r-old", status: "completed", output: [] }, })}\n\n`, { status: 200, headers: { "content-type": "text/event-stream" } }, - )) as typeof fetch; + ), { preconnect: RealFetch.preconnect }); takeSpendHome(); const response = await handleResponses(request(), forwardConfig(), { model: "", provider: "" }); @@ -672,10 +674,10 @@ describe("handleResponses Codex WS relay selection", () => { }], }, }; - globalThis.fetch = (async () => new Response( + globalThis.fetch = Object.assign(async () => new Response( `event: response.completed\ndata: ${JSON.stringify(upstreamEvent)}\n\n`, { status: 200, headers: { "content-type": "text/event-stream" } }, - )) as typeof fetch; + ), { preconnect: RealFetch.preconnect }); takeSpendHome(); const response = await handleResponses(request(), forwardConfig(), { model: "", provider: "" }); @@ -732,7 +734,7 @@ describe("codexWsUpstreamFetch", () => { headers: { authorization: "Bearer fixture", "content-type": "application/json", "x-openai-internal-codex-responses-lite": "true" }, body: JSON.stringify({ model: "gpt-5.5", input: "hello", stream: true, service_tier: "priority" }), }), { - defaultProvider: "openai", providers: { openai: { adapter: "openai-responses", authMode: "forward", codexAccountMode: "direct", baseUrl: "https://chatgpt.com/backend-api/codex" } }, + port: 0, defaultProvider: "openai", providers: { openai: { adapter: "openai-responses", authMode: "forward", codexAccountMode: "direct", baseUrl: "https://chatgpt.com/backend-api/codex" } }, } as OcxConfig, { model: "", provider: "" }, { codexWsRuntimeIdentity: BOUNDED_WS_RUNTIME }); await response.text(); expect(frames).toHaveLength(1); @@ -795,10 +797,10 @@ describe("codexWsUpstreamFetch", () => { process.env.HTTPS_PROXY = proxy; const sentinel = new Response("sse-fallback"); let fallbackCalls = 0; - const response = await codexWsUpstreamFetch(CODEX_URL, streamingInit(), (async () => { + const response = await codexWsUpstreamFetch(CODEX_URL, streamingInit(), Object.assign(async () => { fallbackCalls += 1; return sentinel; - }) as typeof fetch); + }, { preconnect: RealFetch.preconnect })); expect(response).toBe(sentinel); expect(fallbackCalls).toBe(1); @@ -884,10 +886,10 @@ describe("codexWsUpstreamFetch", () => { let fallbacks = 0; const response = await fetchWithTransientRetry(() => { attempts++; - return rawCodexWsUpstreamFetch(url, streamingInit(), (async () => { + return rawCodexWsUpstreamFetch(url, streamingInit(), Object.assign(async () => { fallbacks++; throw new Error("a sent create must not be resent over HTTP"); - }) as typeof fetch, BOUNDED_WS_RUNTIME, onQuota); + }, { preconnect: RealFetch.preconnect }), BOUNDED_WS_RUNTIME, onQuota); }, {}); const ws = FakeWebSocket.instances.at(-1)!; expect(attempts).toBe(1); @@ -1089,7 +1091,7 @@ describe("codexWsUpstreamFetch", () => { ws.emit("open", {}); }); const response = await rawCodexWsUpstreamFetch(url, { ...streamingInit(), signal: abort.signal }, - (async () => { fallbacks++; throw new Error("unexpected fallback"); }) as typeof fetch, BOUNDED_WS_RUNTIME); + Object.assign(async () => { fallbacks++; throw new Error("unexpected fallback"); }, { preconnect: RealFetch.preconnect }), BOUNDED_WS_RUNTIME); const ws = FakeWebSocket.instances.at(-1)!; abort.abort(new Error("late abort")); ws.emit("error", {}); @@ -1116,10 +1118,10 @@ describe("codexWsUpstreamFetch", () => { const prepared = prepareCodexWsRequest(CODEX_URL, init)!; const session = new CodexWsSession("wss://chatgpt.com/backend-api/codex/responses", prepared.headers, true); let fallbacks = 0; - const options = { session, url: CODEX_URL, init, prepared, sseFallback: (async () => { + const options = { session, url: CODEX_URL, init, prepared, sseFallback: Object.assign(async () => { fallbacks++; throw new Error("retained create must not fall back"); - }) as typeof fetch }; + }, { preconnect: RealFetch.preconnect }) }; try { expect(session.reserve()).toBe(true); await (await codexWsExchange(options)).text(); @@ -1163,9 +1165,9 @@ describe("codexWsUpstreamFetch", () => { ws.emit("open", {}); ws.emit("message", { data: JSON.stringify(payload, null, 2) }); }); - const response = await codexWsUpstreamFetch(CODEX_URL, streamingInit(), (async () => { + const response = await codexWsUpstreamFetch(CODEX_URL, streamingInit(), Object.assign(async () => { throw new Error("a sent multiline response cannot fall back"); - }) as typeof fetch); + }, { preconnect: RealFetch.preconnect })); const text = await response.text(); const data = text.split("\n").filter(line => line.startsWith("data: ")); expect(data).toHaveLength(1); @@ -1463,10 +1465,10 @@ describe("codexWsUpstreamFetch", () => { ws.emit("message", { data: JSON.stringify({ type: "codex.response.metadata", headers: { "x-models-etag": "x".repeat(CODEX_WS_METADATA_MAX_BYTES) } }) }); }); let resends = 0; - const response = await codexWsUpstreamFetch(CODEX_URL, streamingInit(), (async () => { + const response = await codexWsUpstreamFetch(CODEX_URL, streamingInit(), Object.assign(async () => { resends++; return new Response("unexpected resend"); - }) as typeof fetch); + }, { preconnect: RealFetch.preconnect })); expect(response.status).toBe(502); expect(isCodexWsUpstreamResponse(response)).toBe(false); expect(isNonReplayableResponse(response)).toBe(true); @@ -1481,10 +1483,10 @@ describe("codexWsUpstreamFetch", () => { installFake(ws => { ws.emit("open", {}); opened.resolve(); }); let http = 0; try { - const pending = codexWsUpstreamFetch(CODEX_URL, streamingInit(), (async () => { + const pending = codexWsUpstreamFetch(CODEX_URL, streamingInit(), Object.assign(async () => { http++; return new Response("must not resend"); - }) as typeof fetch); + }, { preconnect: RealFetch.preconnect })); await opened.promise; const ws = FakeWebSocket.instances[0]; jest.advanceTimersByTime(28_000); @@ -1512,10 +1514,10 @@ describe("codexWsUpstreamFetch", () => { try { const pending = fetchWithTransientRetry(() => { sends++; - return codexWsUpstreamFetch(CODEX_URL, streamingInit(), (async () => { + return codexWsUpstreamFetch(CODEX_URL, streamingInit(), Object.assign(async () => { http++; return new Response("must not resend"); - }) as typeof fetch); + }, { preconnect: RealFetch.preconnect })); }, {}); await opened.promise; jest.advanceTimersByTime(CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS); @@ -1539,10 +1541,10 @@ describe("codexWsUpstreamFetch", () => { // The 90 s bound is a silence bound, not a deadline: a peer that answers pings is alive, // and how long an alive origin may take before response.created belongs to the client's // own deadline and the operator's connectTimeoutMs, not to a fixed number in the proxy. - const noResend = (counter: { http: number }) => (async () => { + const noResend = (counter: { http: number }) => Object.assign(async () => { counter.http++; return new Response("must not resend"); - }) as typeof fetch; + }, { preconnect: RealFetch.preconnect }); test("a peer that answers pings stays alive past the silence bound and still completes with one send", async () => { jest.useFakeTimers(); @@ -1965,7 +1967,7 @@ describe("oversized Codex create frames", () => { const response = await codexWsUpstreamFetch( "https://sub2api.example.com/v1/responses", streamingInit(), - (async () => sentinel) as typeof fetch, + Object.assign(async () => sentinel, { preconnect: RealFetch.preconnect }), ); expect(FakeWebSocket.instances).toHaveLength(0); expect(response).toBe(sentinel); diff --git a/tests/usage/quota-pool.test.ts b/tests/usage/quota-pool.test.ts index 29a13b02baf..d30e9342936 100644 --- a/tests/usage/quota-pool.test.ts +++ b/tests/usage/quota-pool.test.ts @@ -3,7 +3,7 @@ * a local mirror of the contract, absent-not-zero, the window-kind mapping through the existing * Codex header parser, and the opaque, stable `accountRef`. */ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; import { lstatSync, mkdtempSync, readdirSync, readFileSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -22,6 +22,7 @@ import { clearAccountQuotaCache, setCachedProviderAccountQuotaForTests } from ". import { setProviderQuotaReportCache } from "../../src/providers/quota/report-cache"; import { nullPaths, QuotaPoolSchema } from "../helpers/quota-pool-schema"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { COLD_SPAWN_WARMUP_HOOK_BUDGET_MS, warmModuleGraph } from "../helpers/cold-spawn-warmup"; const NOW = Date.parse("2026-10-21T12:00:00.000Z"); const HOUR = 3_600_000; @@ -30,9 +31,16 @@ let home = ""; let priorHome: string | undefined; let admissionPath = ""; let xaiIds: string[] = []; +const IDENTITY_RACE_SCRIPT = `const { ensureQuotaPoolIdentity } = await import(${JSON.stringify(join(import.meta.dir, "../../src/quota-pool/identity.ts"))}); +await Bun.sleep(Math.max(0, Number(process.env.QP_START_AT) - Date.now())); +console.log(ensureQuotaPoolIdentity().homeId);`; +beforeAll(async () => { + await warmModuleGraph({ graph: "quota-pool/identity-race", source: IDENTITY_RACE_SCRIPT }); +}, COLD_SPAWN_WARMUP_HOOK_BUDGET_MS); function config(): OcxConfig { return { + port: 0, defaultProvider: "quiet", providers: { xai: { adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", authMode: "oauth" }, "claude-cli": { adapter: "claude-cli", baseUrl: "http://127.0.0.1:9" }, @@ -234,14 +242,10 @@ describe("quota-pool identity creation", () => { expect(readQuotaPoolIdentity()).toEqual(identity); }); - test("servers racing on a fresh home agree on one identity", async () => { - const script = `const { ensureQuotaPoolIdentity } = await import(${JSON.stringify(join(import.meta.dir, "../../src/quota-pool/identity.ts"))}); -await Bun.sleep(Math.max(0, Number(process.env.QP_START_AT) - Date.now())); -console.log(ensureQuotaPoolIdentity().homeId);`; - for (let round = 0; round < 3; round++) { + test.each([0, 1, 2])("servers racing on a fresh home agree on one identity (round %i)", async () => { const dir = mkdtempSync(join(home, "race-")); const startAt = String(Date.now() + 400); - const runs = Array.from({ length: 4 }, () => Bun.spawn([process.execPath, "-e", script], { + const runs = Array.from({ length: 4 }, () => Bun.spawn([process.execPath, "-e", IDENTITY_RACE_SCRIPT], { env: { ...process.env, OPENCODEX_HOME: dir, QP_START_AT: startAt }, stdout: "pipe", stderr: "pipe", })); // Read each child to completion (stdout, stderr and exit) so a loser that fails names its error. @@ -252,7 +256,6 @@ console.log(ensureQuotaPoolIdentity().homeId);`; resetQuotaPoolIdentityMemoForTests(); const persisted = readQuotaPoolIdentity(dir)!.homeId; expect(results).toEqual(Array.from({ length: 4 }, () => ({ id: persisted, code: 0, error: "" }))); - } }); }); diff --git a/tests/usage/quota-reset-observation.test.ts b/tests/usage/quota-reset-observation.test.ts index fdf00708ea0..28cbc7a21ae 100644 --- a/tests/usage/quota-reset-observation.test.ts +++ b/tests/usage/quota-reset-observation.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { afterEach, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -23,6 +23,7 @@ import { stopQuotaResetPoller, } from "../../src/quota/reset-poller"; import { resetQuotaResetNotifyCacheForTests } from "../../src/quota/reset-notify-config"; +import { COLD_SPAWN_WARMUP_HOOK_BUDGET_MS, warmModuleGraph } from "../helpers/cold-spawn-warmup"; const ACCOUNT = "acct_reset_observation"; const HOUR = 60 * 60_000; @@ -325,6 +326,11 @@ describe("idle poller", () => { }); describe("observation ordering under a burst", () => { + beforeAll(async () => { + // Compile in a separate process; the assertion still starts with a fresh module registry. + await warmModuleGraph({ graph: "quota-reset/cold-burst", entry: + fileURLToPath(new URL("../helpers/quota-reset-burst-child.ts", import.meta.url)) }); + }, COLD_SPAWN_WARMUP_HOOK_BUDGET_MS); test("a rising-usage burst in a COLD process fires nothing", async () => { // The regression this pins: the seam awaited two import() calls before swapping the // baseline, and Bun does not resolve concurrent dynamic imports in call order. A burst of From d610cccb866aaac5119e7ef1551c43e3f40d9537 Mon Sep 17 00:00:00 2001 From: Northstar Date: Tue, 27 Oct 2026 02:05:23 +0800 Subject: [PATCH 093/101] fix(reasoning): separate support, thinking toggles and effort controls (#7531) * fix(reasoning): separate support, thinking toggles and effort controls * fix(reasoning): address capability and wire review findings * fix(reasoning): preserve custom overrides and native thinking budgets * fix(catalog): preserve retained model reasoning provenance * fix(providers): preserve case-varied capability overrides * fix(providers): attribute reasoning exclusions correctly * fix(reasoning): preserve legacy wires and independent capability projections Address six maintainer findings and preserve legacy Chat behavior across native and translated requests. Cover native effort recovery, Responses extension boundaries, tool omission, case-folded overrides, and Claude discovery. * docs(structure): keep integrations.md within its 600-line budget after merging dev The merge with dev put structure/clients/integrations.md at 601 lines; join the reasoning-projection sentence onto the preceding export-metadata line. * fix(reasoning): preserve legacy empty-ladder discovery signal --------- Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> --- .../src/content/docs/guides/providers.md | 7 + .../docs/reference/configuration/providers.md | 2 +- src/adapters/anthropic.ts | 30 +- src/adapters/openai-chat.ts | 9 +- src/adapters/openai-chat/passthrough.ts | 35 +- src/adapters/openai-chat/reasoning-wire.ts | 125 +++++- src/adapters/openai-responses/passthrough.ts | 7 +- src/adapters/openai-responses/reasoning.ts | 13 + src/adapters/reasoning-controls.ts | 16 + src/chat/inbound.ts | 5 +- src/claude/inbound.ts | 3 +- src/claude/model-info.ts | 39 +- src/cli/export-command.ts | 3 + src/cli/opencode.ts | 29 +- src/clients/config-export/contracts.ts | 27 +- .../config-export/reasoning-metadata.ts | 63 ++- src/clients/reasoning-controls.ts | 49 +++ src/codex/catalog/effort.ts | 28 ++ src/codex/catalog/model-hints.ts | 59 ++- src/codex/catalog/model-visibility.ts | 5 +- src/codex/catalog/parsing.ts | 4 + src/codex/catalog/provider-models.ts | 9 +- src/codex/catalog/routed-gather.ts | 35 +- src/config/provider-validation.ts | 11 +- src/providers/derive.ts | 10 + src/providers/mimo-thinking.ts | 12 + src/providers/reasoning-capabilities.ts | 76 ++++ src/providers/registry/entries-extended.ts | 11 +- src/providers/registry/model-ids.ts | 1 + src/providers/registry/types.ts | 3 +- src/providers/resolved-model-policy.ts | 32 +- src/responses/parser.ts | 5 +- src/responses/schema.ts | 1 + src/server/effort-policy.ts | 6 +- src/server/index/serve-options.ts | 5 +- .../management/model-row-export-metadata.ts | 31 +- src/server/management/model-rows.ts | 35 +- src/server/models-capabilities.ts | 24 +- src/server/responses/core-normalize.ts | 12 +- src/types.ts | 1 + src/types/provider.ts | 20 +- src/types/request.ts | 5 + structure/catalog.md | 24 +- structure/clients/claude-desktop.md | 4 +- structure/clients/integrations.md | 4 +- structure/config.md | 8 +- structure/dashboard-and-usage.md | 2 +- structure/data-planes/images.md | 2 +- structure/data-planes/inbound-compat.md | 12 +- structure/ops/docs-and-release.md | 2 +- structure/ops/service-and-sidecars.md | 2 +- structure/providers-and-adapters.md | 14 +- structure/providers/chat-compat.md | 9 +- structure/providers/cursor.md | 2 +- structure/providers/xai-grok.md | 2 +- structure/subagents.md | 2 +- structure/transports/responses-failover.md | 4 +- structure/transports/responses-wire-shapes.md | 2 +- structure/transports/responses.md | 6 +- structure/transports/streaming-health.md | 2 +- .../anthropic/anthropic-reasoning.test.ts | 152 +++++++ .../openai-chat-reasoning-wire-policy.test.ts | 377 ++++++++++++++++++ .../claude-integration/claude-inbound.test.ts | 13 +- .../claude-model-info.test.ts | 73 ++++ .../client-export-effective-metadata.test.ts | 74 ++++ .../client-export-reasoning-controls.test.ts | 131 ++++++ .../client-export-reasoning-metadata.test.ts | 29 +- tests/codex-integration/effort-policy.test.ts | 18 + tests/config/config-load-degrade.test.ts | 22 +- .../cursor/cursor-local-models-schema.test.ts | 51 ++- tests/providers/mimo-v26-catalog.test.ts | 301 +++++++++++++- tests/providers/resolved-model-policy.test.ts | 77 ++++ .../chat-inbound-reasoning-none.test.ts | 10 + .../openai-responses-summary-none.test.ts | 49 ++- ...responses-native-ultra-effort-wire.test.ts | 51 +++ tests/responses/responses-parser.test.ts | 54 +++ tests/server/api-catalog-route.test.ts | 113 +++++- 77 files changed, 2380 insertions(+), 221 deletions(-) create mode 100644 src/adapters/reasoning-controls.ts create mode 100644 src/clients/reasoning-controls.ts create mode 100644 src/providers/mimo-thinking.ts create mode 100644 src/providers/reasoning-capabilities.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index c365b6c573b..17b60809537 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -968,6 +968,13 @@ Most use the `openai-chat` adapter with a bearer key; Anthropic-compatible prese preset, `xiaomi-mimo`, and a token-plan preset, `mimo`. All three default to MiMo V2.6 (`mimo-v2.6-pro`, or `mimo-v2.6-flash` on `xiaomi-mimo`). Xiaomi retires `mimo-v2.5` and `mimo-v2.5-pro` on 2026-10-21 with no redirect, so switch a saved V2.5 default before then; opencodex does not rewrite it for you. + +**Reasoning capabilities.** Model metadata describes three independent capabilities: `supportsReasoning`, `supportsThinkingToggle`, and `supportsReasoningEffort`. An omitted value is unknown; `false` is an explicit declaration. `defaultThinkingEnabled` records the default switch state, while `reasoningEfforts` lists actual strength choices. Per-model declarations live in the provider's `modelCapabilities` map; reasoning axes merge over registry defaults using case-insensitive model IDs, with operator values taking precedence. `/v1/models` exposes the corresponding `supports_reasoning`, `supports_thinking_toggle`, `supports_reasoning_effort`, and `default_thinking_enabled` fields under `capabilities`, without inventing an effort list for fixed-strength models. + +Legacy configurations with an explicit empty effort list and no reasoning capability declarations retain `capabilities.supports_reasoning: false` in `/v1/models` discovery. An explicit `supportsReasoning: true` or `supportsThinkingToggle: true` still advertises thinking when the effort list is empty. + +Public MiMo V2.6 is one instance: its three models support reasoning and an on/off switch, but no adjustable intensity; thinking defaults to enabled. The wire adapter sends `thinking.type: enabled/disabled`. OCX's Responses ingress also accepts `reasoning.enabled` independently of `reasoning.effort`. For clients that only offer effort enums, Codex catalogs and OpenCode/Kilo exports represent this switch as `none/high`; those values are client compatibility aliases, not MiMo strength levels. Client-owned labels may still say “High.” Fixed reasoning models without a switch get no fabricated control. Xiaomi's [deep-thinking guide](https://mimo.mi.com/docs/en-US/quick-start/usage-guide/other/deep-thinking) describes the public MiMo switch; free, token-plan, third-party routes and older MiMo models keep their existing declarations. + Volcengine Coding Plan and Agent Plan use their native Responses endpoints through `openai-responses`. During validated Ark Coding Plan tool continuations, replaying the returned Responses `reasoning` item answered `400 InvalidParameter`, so the Coding Plan preset drops replayed reasoning items before diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index d4d626e0c7a..9ec4cb46ad5 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -362,7 +362,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `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. | -| `reasoningWireFormat?` | `"gateway-object"` | For OpenAI-compatible gateways that accept `reasoning: { enabled, effort }` instead of `reasoning_effort`. The ClinePass preset sets this automatically. A provider save that keeps the destination keeps it; see [What a provider save keeps](#what-a-provider-save-keeps). `PATCH` accepts `"gateway-object"` or `null` to clear it. | +| `reasoningWireFormat?` | `"gateway-object"` | For OpenAI-compatible gateways that accept `reasoning: { enabled, effort }` instead of `reasoning_effort`. For custom Responses destinations, this also explicitly permits the OCX `reasoning.enabled` extension; other Responses destinations strip that internal field. Model thinking capability alone does not opt into this wire format. The ClinePass preset sets this automatically. A provider save that keeps the destination keeps it; see [What a provider save keeps](#what-a-provider-save-keeps). `PATCH` accepts `"gateway-object"` or `null` to clear it. | | `noReasoningModels?` | `string[]` | Models that reject reasoning/thinking parameters. | | `noTemperatureModels?` | `string[]` | Models that reject caller-specified `temperature`. | | `noTopPModels?` | `string[]` | Models that reject caller-specified `top_p`. | diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 643250d9680..ecbbfe9af47 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -35,6 +35,9 @@ import { decodeServerSentEvents } from "../lib/sse-decoder"; import { isLikelyRealAnthropicThinkingSignature } from "../responses/reasoning-envelope"; import { isTranslatorBudgetExceededError, retainTranslatedEventBatch, type TranslatorBudget } from "../lib/translator-budget"; import { isReasoningEffortOmitted, modelRecordValue } from "../reasoning-effort"; +import { resolveThinkingEnabled } from "../clients/reasoning-controls"; +import { resolveReasoningCapabilities } from "../providers/reasoning-capabilities"; +import { declaresThinkingToggleWire, reasoningControlsAreUnsupported } from "./reasoning-controls"; import { applyAgentRouterLanguageFraming, isAgentRouterEndpoint } from "./agentrouter"; import { anthropicCacheWrite1hTokens, resolveAnthropicCacheRetention, type AnthropicCacheRetention } from "./anthropic/cache-retention"; import { claudeFamilyVersion, rejectsCombinedSampling, rejectsForcedToolChoice, rejectsSamplingParameters, supportsExplicitThinkingDisable, usesAdaptiveThinking, usesBetweenToolsFloor } from "./anthropic-model-contract"; @@ -1082,16 +1085,33 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti // model both defaults to thinking and accepts being told not to. const family = claudeFamilyVersion(parsed.modelId); progressUpdates = usesNativeAnthropicEndpoint(provider) && family?.family === "opus" && family.major === 5 && family.minor === 5; - const effectiveReasoning = parsed.options.reasoning ?? defaultReasoningEffort(provider, parsed.modelId); - if (effectiveReasoning === "none" && supportsExplicitThinkingDisable(parsed.modelId)) { + const thinkingToggleWire = declaresThinkingToggleWire(provider, parsed.modelId); + const reasoningControlsUnsupported = reasoningControlsAreUnsupported(provider, parsed.modelId); + const capabilities = resolveReasoningCapabilities(provider, parsed.modelId); + const thinkingEnabled = thinkingToggleWire + ? resolveThinkingEnabled(provider, parsed.modelId, parsed.options) + : undefined; + const nativeThinkingContract = usesAdaptiveThinking(parsed.modelId) + || family?.family === "haiku" || family?.family === "sonnet" || family?.family === "opus"; + const supportsReasoningEffort = capabilities.supportsReasoningEffort === true; + const requestedReasoning = parsed.options.reasoning ?? defaultReasoningEffort(provider, parsed.modelId); + // Native Claude wire constraints outrank a generic toggle declaration. Decode the boolean + // into native effort intent, then let the family contract choose budget/adaptive/off shapes. + const effectiveReasoning = nativeThinkingContract && thinkingEnabled !== undefined + ? thinkingEnabled ? (requestedReasoning && requestedReasoning !== "none" ? requestedReasoning : "high") : "none" + : requestedReasoning; + if (!reasoningControlsUnsupported && thinkingEnabled !== undefined && !nativeThinkingContract) { + body.thinking = { type: thinkingEnabled ? "enabled" : "disabled" }; + } + if (!reasoningControlsUnsupported && (thinkingEnabled === undefined || nativeThinkingContract) && effectiveReasoning === "none" && supportsExplicitThinkingDisable(parsed.modelId)) { body.thinking = { type: "disabled" }; - } else if (effectiveReasoning === "none" && usesBetweenToolsFloor(parsed.modelId)) { + } else if (!reasoningControlsUnsupported && (thinkingEnabled === undefined || nativeThinkingContract) && effectiveReasoning === "none" && usesBetweenToolsFloor(parsed.modelId)) { // Sonnet 5.5 rejects `disabled`; `between_tools` is its lowest setting and turns off up-front // thinking. No effort is sent: the API default (high) is inside the range it accepts. body.thinking = { type: "between_tools" }; delete body.temperature; delete body.top_p; - } else if (typeof effectiveReasoning === "string" && effectiveReasoning !== "none") { + } else if (!reasoningControlsUnsupported && thinkingEnabled !== false && (thinkingEnabled === undefined || supportsReasoningEffort || nativeThinkingContract) && typeof effectiveReasoning === "string" && effectiveReasoning !== "none") { if (usesAdaptiveThinking(parsed.modelId)) { // Adaptive-thinking models replace the token budget with an effort knob and reject // `thinking.type: "enabled"` outright. `max_tokens` still caps thinking plus visible @@ -1148,7 +1168,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti delete body.top_p; } - if (progressUpdates) body.thinking = { type: "adaptive", display: "updates" }; + if (progressUpdates && !reasoningControlsUnsupported && thinkingEnabled !== false) body.thinking = { type: "adaptive", display: "updates" }; if (rejectsSamplingParameters(parsed.modelId)) { // Opus 4.7+, Sonnet 5+, Haiku 5.5+ and Fable 400 on non-default sampling, with or without diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index bd77e101a3b..dd64a615f1c 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -2,10 +2,9 @@ import { withRequiredChatToolResult } from "./openai-chat/required-tool-result"; import { hasShrinkableOpenAIChatImages, normalizeOpenAIChatImages } from "./openai-chat-images"; import { protectGlmSummaryBudget, resolveMaxTokens } from "./openai-chat/summary-budget"; import { chatParallelToolCallsWireValue } from "./openai-chat/parallel-tool-calls"; -import { applyExplicitChatReasoningWirePolicy } from "./openai-chat/reasoning-wire"; +import { applyExplicitChatReasoningWirePolicy, declaresIndependentChatReasoningEffort } from "./openai-chat/reasoning-wire"; import type { AdapterRequest, IncomingMeta, ProviderAdapter } from "./base"; -import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig, OcxUsage } from "../types"; -import { modelInList } from "../types"; +import { modelInList, type AdapterEvent, type OcxParsedRequest, type OcxProviderConfig, type OcxUsage } from "../types"; import { mapReasoningEffort } from "../reasoning-effort"; import { debugProviderDiagnostic } from "../lib/debug"; import { sseFieldValue } from "../lib/sse-decoder"; @@ -153,7 +152,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd const explicitReasoning = applyExplicitChatReasoningWirePolicy({ provider, modelId: parsed.modelId, - hasTools: !!tools, + hasTools: !!tools, thinkingEnabled: parsed.options.thinkingEnabled, requestedEffort, wireEffort: reasoningEffort, reasoningDisabled, @@ -173,7 +172,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd wireValue: budget, }; } - } else if (modelInList(provider.thinkingToggleModels, parsed.modelId)) { + } else if (modelInList(provider.thinkingToggleModels, parsed.modelId) && !declaresIndependentChatReasoningEffort(provider, parsed.modelId)) { if (reasoningEffort === "enabled" || reasoningEffort === "disabled" || reasoningEffort === "adaptive") { body.thinking = { type: reasoningEffort }; reasoningLog = { diff --git a/src/adapters/openai-chat/passthrough.ts b/src/adapters/openai-chat/passthrough.ts index 5a7165e5947..fdcedf66d7c 100644 --- a/src/adapters/openai-chat/passthrough.ts +++ b/src/adapters/openai-chat/passthrough.ts @@ -10,10 +10,12 @@ import { applyGithubCopilotContextTier } from "../../providers/github-copilot-co import { canonicalFastTierMarker, decideTier, type ResolvedFastPolicy } from "../../providers/fastwire"; import { debugProviderDiagnostic } from "../../lib/debug"; import { isDebugEnabled } from "../../lib/debug-settings"; -import { modelRecordValue } from "../../reasoning-effort"; import { modelInList, type OcxProviderConfig } from "../../types"; +import { mapReasoningEffort, modelRecordValue } from "../../reasoning-effort"; +import { resolveReasoningCapabilities } from "../../providers/reasoning-capabilities"; import { chatParallelToolCallsWireValue } from "./parallel-tool-calls"; -import { applyExplicitChatReasoningWirePolicy } from "./reasoning-wire"; +import { applyExplicitChatReasoningWirePolicy, declaresIndependentChatReasoningControls } from "./reasoning-wire"; +import { declaresThinkingToggleWire, reasoningControlsAreUnsupported } from "../reasoning-controls"; import { planNativeChatReasoningReplay } from "./reasoning-replay"; import type { OcxReasoningReplayScopeRef } from "../../types"; import { jsonUtf8Bytes } from "../../lib/json-byte-size"; @@ -87,22 +89,40 @@ export function buildOpenAIChatPassthroughRequest( const replayPlan = planNativeChatReasoningReplay(body.messages, provider, modelId, replayScope, Array.isArray(rawBody.tools) && rawBody.tools.length > 0, maxSerializedBytes); if (protectGlmSummaryBudget(body, provider.baseUrl, body.reasoning_effort)) body.reasoning_effort = "low"; + const toggleWire = declaresThinkingToggleWire(provider, modelId); + const independentControls = declaresIndependentChatReasoningControls(provider, modelId); const rawEfforts = modelRecordValue(provider.modelReasoningEfforts, modelId) ?? provider.reasoningEfforts; - const reasoningDisabled = modelInList(provider.noReasoningModels, modelId) || rawEfforts?.length === 0; + const reasoningDisabled = modelInList(provider.noReasoningModels, modelId) + || (!toggleWire && rawEfforts?.length === 0) || reasoningControlsAreUnsupported(provider, modelId); if (reasoningDisabled) { delete body.reasoning_effort; } const hasTools = Array.isArray(rawBody.tools) && rawBody.tools.length > 0; const requestedEffort = typeof body.reasoning_effort === "string" ? body.reasoning_effort : undefined; - applyExplicitChatReasoningWirePolicy({ + const explicitThinkingEnabled = rawBody.reasoning !== null && typeof rawBody.reasoning === "object" + && typeof (rawBody.reasoning as { enabled?: unknown }).enabled === "boolean" + ? (rawBody.reasoning as { enabled: boolean }).enabled + : undefined; + const explicitReasoning = applyExplicitChatReasoningWirePolicy({ provider, modelId, hasTools, + thinkingEnabled: explicitThinkingEnabled, requestedEffort, - wireEffort: requestedEffort, + wireEffort: toggleWire && independentControls ? mapReasoningEffort(provider, modelId, requestedEffort) : requestedEffort, reasoningDisabled, body, }); + let reasoningLog = explicitReasoning.handled ? explicitReasoning.reasoningLog : undefined; + // A native caller's own thinking object is already in the upstream wire shape. Forward it only + // on a provider-declared toggle wire, below an explicit reasoning.enabled but above a default. + if (!reasoningDisabled && toggleWire && independentControls + && resolveReasoningCapabilities(provider, modelId).supportsThinkingToggle !== false + && explicitThinkingEnabled === undefined + && rawBody.thinking !== undefined) { + body.thinking = rawBody.thinking; + reasoningLog = undefined; + } const openRouterRouting = resolveOpenRouterRouting(provider, modelId); if (openRouterRouting) body.provider = openRouterProviderPayload(openRouterRouting); @@ -208,5 +228,8 @@ export function buildOpenAIChatPassthroughRequest( }); } - return { url, method: "POST", headers, body: bodyJson }; + return { + url, method: "POST", headers, body: bodyJson, + ...(reasoningLog ? { reasoningLog } : {}), + }; } diff --git a/src/adapters/openai-chat/reasoning-wire.ts b/src/adapters/openai-chat/reasoning-wire.ts index ac53a242352..c63c48a3491 100644 --- a/src/adapters/openai-chat/reasoning-wire.ts +++ b/src/adapters/openai-chat/reasoning-wire.ts @@ -1,11 +1,34 @@ +import { resolveThinkingEnabled } from "../../clients/reasoning-controls"; +import { declaredModelCapabilities, resolveReasoningCapabilities } from "../../providers/reasoning-capabilities"; +import { registryEntryForProviderDestination } from "../../providers/registry"; import { modelInList, type OcxProviderConfig } from "../../types"; import type { AdapterRequest } from "../base"; +import { declaresThinkingToggleWire, reasoningControlsAreUnsupported } from "../reasoning-controls"; import { isNativeOpenAIChatTarget } from "./wire"; export type ExplicitChatReasoningWireResult = | { handled: false } | { handled: true; reasoningLog?: AdapterRequest["reasoningLog"] }; +function isThinkingToggleType(value: string | undefined): value is "enabled" | "disabled" | "adaptive" { + return value === "enabled" || value === "disabled" || value === "adaptive"; +} + +/** A legacy ladder alone must not opt a translated toggle wire into a second control. */ +export function declaresIndependentChatReasoningEffort(provider: OcxProviderConfig, modelId: string): boolean { + const explicit = declaredModelCapabilities(provider.modelCapabilities, modelId)?.supportsReasoningEffort; + const registry = declaredModelCapabilities(registryEntryForProviderDestination(provider)?.modelCapabilities, modelId)?.supportsReasoningEffort; + return (explicit ?? registry) === true; +} + +/** Only explicit control axes opt legacy Chat destinations into independent projection. */ +export function declaresIndependentChatReasoningControls(provider: OcxProviderConfig, modelId: string): boolean { + const explicit = declaredModelCapabilities(provider.modelCapabilities, modelId); + const registry = declaredModelCapabilities(registryEntryForProviderDestination(provider)?.modelCapabilities, modelId); + return (["supportsThinkingToggle", "supportsReasoningEffort"] as const) + .some(axis => typeof (explicit?.[axis] ?? registry?.[axis]) === "boolean"); +} + /** * Apply provider-declared reasoning wire policy after the effective provider is resolved. * Unset declarations are a no-op so native Chat keeps forwarding the caller's raw field. @@ -14,6 +37,7 @@ export function applyExplicitChatReasoningWirePolicy(options: { provider: OcxProviderConfig; modelId: string; hasTools: boolean; + thinkingEnabled: boolean | undefined; requestedEffort: string | undefined; wireEffort: string | undefined; reasoningDisabled: boolean; @@ -23,19 +47,103 @@ export function applyExplicitChatReasoningWirePolicy(options: { provider, modelId, hasTools, + thinkingEnabled, requestedEffort, - wireEffort, + wireEffort: mappedWireEffort, reasoningDisabled, body, } = options; - if (reasoningDisabled) return { handled: false }; + const capabilities = resolveReasoningCapabilities(provider, modelId); + if (reasoningDisabled || reasoningControlsAreUnsupported(provider, modelId)) { + delete body.reasoning_effort; + delete body.reasoning; + delete body.thinking; + return { handled: true }; + } + const toggleWire = declaresThinkingToggleWire(provider, modelId); + const toggleDisabled = toggleWire && capabilities.supportsThinkingToggle === false; + // Legacy tool omission precedes discriminator mapping; independent switches are separate + // from strength omission and retain their declared behavior below. + if (hasTools && modelInList(provider.omitReasoningEffortWithToolsModels, modelId) + && !declaresIndependentChatReasoningControls(provider, modelId)) { + delete body.reasoning_effort; + delete body.reasoning; + return { handled: true }; + } + // Ignore legacy toggle discriminators on a disabled toggle axis, but retain real effort + // mappings (including omission) and run them through the shared effort policy below. + const requestedStrength = requestedEffort === "ultra" ? "max" : requestedEffort; + const wireEffort = toggleDisabled && isThinkingToggleType(mappedWireEffort) ? requestedStrength : mappedWireEffort; + if (capabilities.supportsReasoningEffort === false && !toggleWire) { + delete body.reasoning_effort; + delete body.reasoning; + if (capabilities.supportsThinkingToggle === false) delete body.thinking; + return { handled: true }; + } + const resolvedThinkingEnabled = toggleWire && !toggleDisabled + ? resolveThinkingEnabled(provider, modelId, { thinkingEnabled, reasoning: requestedEffort }) + : undefined; + if (toggleDisabled) { + delete body.thinking; + delete body.reasoning; + delete body.reasoning_effort; + } else if (toggleWire) { + // These values are the provider's declared thinking discriminator, never strengths. This + // remains true when the catalog also advertises a separate, real effort axis. + if (isThinkingToggleType(wireEffort)) { + const thinkingType = resolvedThinkingEnabled === undefined + ? wireEffort + : resolvedThinkingEnabled ? "enabled" : "disabled"; + body.thinking = { type: thinkingType }; + delete body.reasoning_effort; + delete body.reasoning; + return { + handled: true, + reasoningLog: { + effectiveEffort: thinkingType, + wireField: "thinking.type", + wireValue: thinkingType, + }, + }; + } + if (resolvedThinkingEnabled !== undefined) { + const thinkingType = resolvedThinkingEnabled ? "enabled" : "disabled"; + body.thinking = { type: thinkingType }; + // A second control still goes through tool omission and gateway projection below. + if (capabilities.supportsReasoningEffort === false) { + delete body.reasoning_effort; + delete body.reasoning; + return { + handled: true, + reasoningLog: { + effectiveEffort: thinkingType, + wireField: "thinking.type", + wireValue: thinkingType, + }, + }; + } + } + if (capabilities.supportsReasoningEffort === false) { + delete body.reasoning_effort; + delete body.reasoning; + return { handled: true }; + } + } if (hasTools && modelInList(provider.omitReasoningEffortWithToolsModels, modelId)) { delete body.reasoning_effort; delete body.reasoning; return { handled: true }; } - if (provider.reasoningWireFormat !== "gateway-object") return { handled: false }; + if (provider.reasoningWireFormat !== "gateway-object") { + if (!toggleDisabled) return { handled: false }; + if (wireEffort === undefined) return { handled: true }; + body.reasoning_effort = wireEffort; + return { + handled: true, + reasoningLog: { effectiveEffort: wireEffort, wireField: "reasoning_effort", wireValue: wireEffort }, + }; + } const nativeOpenAI = isNativeOpenAIChatTarget(provider); if (requestedEffort === "none") { @@ -52,17 +160,18 @@ export function applyExplicitChatReasoningWirePolicy(options: { }; } delete body.reasoning_effort; - body.reasoning = { enabled: false }; + const enabled = resolvedThinkingEnabled ?? false; + body.reasoning = { enabled }; return { handled: true, reasoningLog: { - effectiveEffort: "none", + effectiveEffort: enabled ? "enabled" : "none", wireField: "reasoning.enabled", - wireValue: false, + wireValue: enabled, }, }; } - if (wireEffort === undefined) return { handled: false }; + if (wireEffort === undefined) return { handled: toggleDisabled }; if (nativeOpenAI) { delete body.reasoning; @@ -77,7 +186,7 @@ export function applyExplicitChatReasoningWirePolicy(options: { }; } delete body.reasoning_effort; - body.reasoning = { enabled: true, effort: wireEffort }; + body.reasoning = { enabled: resolvedThinkingEnabled ?? true, effort: wireEffort }; return { handled: true, reasoningLog: { diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index e8abc6aab58..86ae5e5340b 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -39,7 +39,7 @@ import { createAdapterTierMetadata, emittedFastWire, } from "../../providers/fastwire"; -import { dropResponsesReasoningInputItems, mapRoutedResponsesReasoningEffort, normalizeConfiguredReasoningSummaryDelivery, sanitizeReasoningInputContent, stripDisabledReasoningSummaries, stripDisabledVerbosity, stripNoneReasoningSummary, stripUnsupportedReasoningSummaryDelivery } from "./reasoning"; +import { dropResponsesReasoningInputItems, mapRoutedResponsesReasoningEffort, normalizeConfiguredReasoningSummaryDelivery, sanitizeReasoningInputContent, stripDisabledReasoningSummaries, stripDisabledVerbosity, stripInternalReasoningEnabled, stripNoneReasoningSummary, stripUnsupportedReasoningSummaryDelivery } from "./reasoning"; import { repairUrlCitationAnnotations, scrubOcxCompactionItems, stripCanonicalOnlyInputItems, stripCanonicalOnlyToolFields, stripCanonicalOnlyTopLevelFields, stripInternalChatMessageMetadataPassthrough, stripInvalidItemIds, stripItemIdsWhenUnstored, stripProviderInputItemMetadata, stripRejectedSamplingParams } from "./request-strips"; import { notePlaintextReasoningDropped } from "./plaintext-reasoning-notice"; import { stripCanonicalForwardPromptCacheOptions, stripDeprecatedPromptCacheRetention } from "./prompt-cache"; @@ -515,7 +515,10 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): if (!isCanonicalOpenAiForwardProvider(provider)) { validateFinalCustomToolCompatibility(finalBody, provider.supportsResponsesCustomTools); } - const wireBody = applyGithubCopilotContextTier(finalBody, provider, parsed.modelId, incoming.providerName); + let wireBody = applyGithubCopilotContextTier(finalBody, provider, parsed.modelId, incoming.providerName); + if (isOpenAiOperatedResponsesDestination(provider) || provider.reasoningWireFormat !== "gateway-object") { + wireBody = stripInternalReasoningEnabled(wireBody); + } const body = JSON.stringify(wireBody); const releaseBodyObservation = translatorBudget.observeExternallyCapped( "passthrough_serialization", diff --git a/src/adapters/openai-responses/reasoning.ts b/src/adapters/openai-responses/reasoning.ts index 2fd591aea06..4ba2f26503c 100644 --- a/src/adapters/openai-responses/reasoning.ts +++ b/src/adapters/openai-responses/reasoning.ts @@ -325,6 +325,19 @@ export function stripDisabledVerbosity( }; } +/** + * `reasoning.enabled` is an OCX inbound compatibility extension, not an OpenAI Responses + * parameter. Strip it at Responses egress unless a custom destination explicitly declares + * the gateway-object wire; thinking capability alone does not declare a wire format. + */ +export function stripInternalReasoningEnabled(body: unknown): unknown { + if (!isPlainObject(body) || !isPlainObject(body.reasoning) || !Object.hasOwn(body.reasoning, "enabled")) { + return body; + } + const { enabled: _enabled, ...reasoning } = body.reasoning; + return { ...body, ...(Object.keys(reasoning).length > 0 ? { reasoning } : { reasoning: undefined }) }; +} + /** * Normalize only the delivery enum Codex already emitted. Do not inject a field into callers that * did not request summaries, and leave every unconfigured provider/model byte-for-byte unchanged. diff --git a/src/adapters/reasoning-controls.ts b/src/adapters/reasoning-controls.ts new file mode 100644 index 00000000000..520795ec7af --- /dev/null +++ b/src/adapters/reasoning-controls.ts @@ -0,0 +1,16 @@ +import { registryEntryForProviderDestination } from "../providers/registry"; +import { resolveReasoningCapabilities } from "../providers/reasoning-capabilities"; +import { modelInList, type OcxProviderConfig } from "../types"; + +/** A thinking toggle is serialized only on a provider-declared wire. */ +export function declaresThinkingToggleWire(provider: OcxProviderConfig, modelId: string): boolean { + return modelInList(provider.thinkingToggleModels, modelId) + || modelInList(registryEntryForProviderDestination(provider)?.thinkingToggleModels, modelId); +} + +/** Explicitly unavailable controls constrain both translated and native requests. */ +export function reasoningControlsAreUnsupported(provider: OcxProviderConfig, modelId: string): boolean { + const capabilities = resolveReasoningCapabilities(provider, modelId); + return capabilities.supportsReasoning === false + || (capabilities.supportsThinkingToggle === false && capabilities.supportsReasoningEffort === false); +} diff --git a/src/chat/inbound.ts b/src/chat/inbound.ts index 4e60b362e2b..a602efce8f7 100644 --- a/src/chat/inbound.ts +++ b/src/chat/inbound.ts @@ -672,9 +672,12 @@ function applyChatSettings(raw: ChatCompletionsRoutingBody, body: Rec): void { const effort = resolveReasoningEffort(raw); const summary = resolveReasoningSummary(raw); - if (effort || summary !== undefined) { + const enabled = isRec(raw.reasoning) && typeof raw.reasoning.enabled === "boolean" ? raw.reasoning.enabled + : isRec(raw.thinking) && ["enabled", "disabled"].includes(String(raw.thinking.type)) ? raw.thinking.type === "enabled" : undefined; + if (effort || summary !== undefined || enabled !== undefined) { body.reasoning = { ...(effort ? { effort } : {}), + ...(enabled !== undefined ? { enabled } : {}), summary: summary ?? "auto", }; } diff --git a/src/claude/inbound.ts b/src/claude/inbound.ts index 8766552b085..55c5dd3aba9 100644 --- a/src/claude/inbound.ts +++ b/src/claude/inbound.ts @@ -631,9 +631,10 @@ function applyAnthropicSettings( // think by default, omission means thinking is ON, sharing the caller's max_tokens (#545). // `none` is the effort disable sentinel. It is not a valid OpenAI summary // value, so do not attach the similarly named internal catalog sentinel. - body.reasoning = { effort: "none" }; + body.reasoning = { effort: "none", enabled: false }; } else if (isRec(thinking) || outputConfigEffort !== undefined) { const reasoning: Rec = { summary: isRec(thinking) && thinking.display === "omitted" ? "none" : "auto" }; + if (isRec(thinking) && thinking.type === "enabled") reasoning.enabled = true; if (outputConfigEffort !== undefined) { // Adaptive wire: /effort arrives as output_config.effort (devlog 080). reasoning.effort = outputConfigEffort; diff --git a/src/claude/model-info.ts b/src/claude/model-info.ts index 649a81e17e8..37b75011161 100644 --- a/src/claude/model-info.ts +++ b/src/claude/model-info.ts @@ -45,8 +45,23 @@ function effortCapability(ladder: readonly string[]) { }; } -function modelCapabilities(ladder: readonly string[], imageInput: boolean) { - const reasons = ladder.length > 0; +function modelCapabilities( + ladder: readonly string[], + imageInput: boolean, + thinkingCapabilities?: Pick, +) { + const reasoningSupported = thinkingCapabilities?.supportsReasoning !== false; + const discoveredEffort = effortCapability(ladder); + const effort = reasoningSupported && thinkingCapabilities?.supportsReasoningEffort !== false + ? discoveredEffort + : effortCapability([]); + // Keep the old ladder projection only while the relevant axis is unknown. An explicit + // toggle proves `enabled`; a usable effort rung proves `adaptive`; neither implies the other. + // This lets clients select MiMo's enabled-only switch without inventing effort. + const adaptive = effort.supported; + const enabled = reasoningSupported && (thinkingCapabilities?.supportsThinkingToggle === true + || (thinkingCapabilities?.supportsThinkingToggle === undefined && effort.supported)); + const thinkingSupported = thinkingCapabilities?.supportsReasoning ?? (adaptive || enabled); return { batch: cap(false), citations: cap(false), @@ -57,12 +72,12 @@ function modelCapabilities(ladder: readonly string[], imageInput: boolean) { clear_tool_uses_20250919: null, compact_20260112: null, }, - effort: effortCapability(ladder), + effort, image_input: cap(imageInput), pdf_input: cap(false), structured_outputs: cap(false), - thinking: reasons - ? { supported: true, types: { adaptive: cap(true), enabled: cap(true) } } + thinking: thinkingSupported + ? { supported: true, types: { adaptive: cap(adaptive), enabled: cap(enabled) } } : { supported: false, types: { adaptive: cap(false), enabled: cap(false) } }, }; } @@ -88,13 +103,21 @@ export interface AnthropicModelInfo { description?: string; } -function modelInfo(id: string, displayName: string, ladder: readonly string[], imageInput: boolean, contextWindow?: number, description?: string): AnthropicModelInfo { +function modelInfo( + id: string, + displayName: string, + ladder: readonly string[], + imageInput: boolean, + contextWindow?: number, + description?: string, + thinkingCapabilities?: Pick, +): AnthropicModelInfo { return { id, display_name: displayName, type: "model", created_at: MODEL_INFO_CREATED_AT, - capabilities: modelCapabilities(ladder, imageInput), + capabilities: modelCapabilities(ladder, imageInput, thinkingCapabilities), max_input_tokens: typeof contextWindow === "number" && contextWindow > 0 ? contextWindow : null, max_tokens: null, ...(description === undefined ? {} : { description }), @@ -242,7 +265,7 @@ export function buildAnthropicModelInfos( : m.maxInputTokens) : undefined; const description = idStyle === "readable" ? `Routed by OpenCodex to ${m.provider}/${listedModelId}` : undefined; - const info = modelInfo(id, `${listedModelId} (${m.provider})`, ladder, imageInput, routedMaxInput ?? m.contextWindow, description); + const info = modelInfo(id, `${listedModelId} (${m.provider})`, ladder, imageInput, routedMaxInput ?? m.contextWindow, description, m); out.push(info); // Anthropic passthrough guard (audit 021 #3): never auto-widen canonical claude // routes — only a genuine >=1M window earns the variant row there. diff --git a/src/cli/export-command.ts b/src/cli/export-command.ts index 00e5e9a7523..cd1e7319efb 100644 --- a/src/cli/export-command.ts +++ b/src/cli/export-command.ts @@ -94,6 +94,9 @@ export function exportModelsFromProxyRows( if (entry.maxTokens !== undefined) model.maxTokens = entry.maxTokens; if (entry.maxInputTokens !== undefined) model.maxInputTokens = entry.maxInputTokens; if (entry.reasoningEfforts !== undefined) model.reasoningEfforts = [...entry.reasoningEfforts]; + if (entry.supportsThinkingToggle !== undefined) model.supportsThinkingToggle = entry.supportsThinkingToggle; + if (entry.supportsReasoningEffort !== undefined) model.supportsReasoningEffort = entry.supportsReasoningEffort; + if (entry.defaultThinkingEnabled !== undefined) model.defaultThinkingEnabled = entry.defaultThinkingEnabled; if (entry.defaultReasoningEffort) model.defaultReasoningEffort = entry.defaultReasoningEffort; if (entry.inputModalities && entry.inputModalities.length > 0) { model.inputModalities = [...entry.inputModalities]; diff --git a/src/cli/opencode.ts b/src/cli/opencode.ts index 059e5931d60..ab66e8dd253 100644 --- a/src/cli/opencode.ts +++ b/src/cli/opencode.ts @@ -40,6 +40,7 @@ import type { OpencodeV2ProviderBlock, } from "../clients/config-export"; import type { EffectiveModelExportMetadata } from "../clients/config-export/contracts"; +import { resolveExportReasoningAxes } from "../clients/config-export/reasoning-metadata"; import { filterCatalogVisibleModels, visibleNativeSlugs } from "../codex/catalog"; import { commandInvocation } from "../lib/win-exec"; import { configuredAdminToken, opencodeCatalogToken } from "../lib/admin-secrets"; @@ -91,6 +92,11 @@ export interface OpencodeRoutedModel { displayName?: string; /** Declared effort ladder; exported as opencode model variants when present. */ reasoningEfforts?: readonly string[]; + supportsReasoning?: boolean; + supportsThinkingToggle?: boolean; + supportsReasoningEffort?: boolean; + defaultThinkingEnabled?: boolean; + defaultReasoningEffort?: string; } /** Row shape from authenticated GET /api/models on the running proxy. */ @@ -120,6 +126,9 @@ export interface OpencodeProxyModelRow { inputModalities?: string[]; /** Declared effort ladder from `/api/models`; carried into opencode model variants. */ reasoningEfforts?: string[]; + supportsThinkingToggle?: boolean; + supportsReasoningEffort?: boolean; + defaultThinkingEnabled?: boolean; /** Declared default effort from `/api/models`. */ defaultReasoningEffort?: string; /** @@ -180,9 +189,14 @@ function opencodeLaunchCatalog( id: model.id, contextWindow: model.contextWindow, displayName: model.displayName, - ...(model.reasoningEfforts && model.reasoningEfforts.length > 0 + ...(model.reasoningEfforts !== undefined ? { reasoningEfforts: [...model.reasoningEfforts] } : {}), + ...(model.supportsReasoning !== undefined ? { supportsReasoning: model.supportsReasoning } : {}), + ...(model.supportsThinkingToggle !== undefined ? { supportsThinkingToggle: model.supportsThinkingToggle } : {}), + ...(model.supportsReasoningEffort !== undefined ? { supportsReasoningEffort: model.supportsReasoningEffort } : {}), + ...(model.defaultThinkingEnabled !== undefined ? { defaultThinkingEnabled: model.defaultThinkingEnabled } : {}), + ...(model.defaultReasoningEffort !== undefined ? { defaultReasoningEffort: model.defaultReasoningEffort } : {}), })), ]; } @@ -372,14 +386,8 @@ export function opencodeCatalogFromProxyRows( const maxTokens = effective?.maxTokens ?? row.maxOutputTokens; const maxInputTokens = effective?.maxInputTokens ?? row.maxInputTokens; const inputModalities = effective?.inputModalities ?? row.inputModalities; - const reasoningEfforts = effective?.reasoningEfforts ?? row.reasoningEfforts; - // The projection is the authority on the default: when it exists, an absent default means - // NO declared default, and falling back to the row would reintroduce the picker's - // synthetic medium/low value. Rows without one (older hubs, hand-built rows) keep the - // row's own value. - const defaultReasoningEffort = effective !== undefined - ? effective.defaultReasoningEffort - : row.defaultReasoningEffort; + const { supportsThinkingToggle, supportsReasoningEffort, defaultThinkingEnabled, + reasoningEfforts, defaultReasoningEffort } = resolveExportReasoningAxes(effective, row); const supportsTools = effective?.supportsTools ?? row.supportsTools; const supportsReasoning = effective?.supportsReasoning ?? row.supportsReasoning; const supportsReasoningSummaries = effective?.supportsReasoningSummaries ?? row.supportsReasoningSummaries; @@ -398,6 +406,9 @@ export function opencodeCatalogFromProxyRows( ...(typeof row.fastRowAvailable === "boolean" ? { fastRowAvailable: row.fastRowAvailable } : {}), // `[]` is a declaration, not an absence: an explicit no-rungs ladder stays empty here. ...(Array.isArray(reasoningEfforts) ? { reasoningEfforts: [...reasoningEfforts] } : {}), + ...(typeof supportsThinkingToggle === "boolean" ? { supportsThinkingToggle } : {}), + ...(typeof supportsReasoningEffort === "boolean" ? { supportsReasoningEffort } : {}), + ...(typeof defaultThinkingEnabled === "boolean" ? { defaultThinkingEnabled } : {}), ...(typeof defaultReasoningEffort === "string" && defaultReasoningEffort.length > 0 ? { defaultReasoningEffort } : {}), diff --git a/src/clients/config-export/contracts.ts b/src/clients/config-export/contracts.ts index dd173910e72..17a5b4d07f4 100644 --- a/src/clients/config-export/contracts.ts +++ b/src/clients/config-export/contracts.ts @@ -57,10 +57,13 @@ export interface OpencodeCatalogModel { inputModalities?: readonly string[]; /** Declared effort ladder. Exported as opencode model variants where the client reads them. */ reasoningEfforts?: readonly string[]; + supportsThinkingToggle?: boolean; + supportsReasoningEffort?: boolean; + defaultThinkingEnabled?: boolean; /** * Declared default effort. Carried so every client export reads one deduped, visibility- - * filtered ladder per model. The opencode serializer deliberately does NOT turn it into a - * model-level setting — see {@link opencodeEffortVariants} for why. + * filtered ladder per model. Client serializers map it into their own model defaults + * and allow the selected variant to override it. */ defaultReasoningEffort?: string; supportsTools?: boolean; @@ -90,6 +93,9 @@ export interface ExportModel { inputModalities?: string[]; /** Optional effort ladder exported only to clients that support it. */ reasoningEfforts?: string[]; + supportsThinkingToggle?: boolean; + supportsReasoningEffort?: boolean; + defaultThinkingEnabled?: boolean; defaultReasoningEffort?: string; supportsTools?: boolean; supportsReasoning?: boolean; @@ -116,6 +122,9 @@ export interface EffectiveModelExportMetadata { inputModalities?: string[]; /** Effective ladder; `[]` is an explicit "no rungs" declaration and survives as `[]`. */ reasoningEfforts?: string[]; + supportsThinkingToggle?: boolean; + supportsReasoningEffort?: boolean; + defaultThinkingEnabled?: boolean; /** * A default some canonical source declared — never one synthesized from the ladder's * preference order, and only when it is a member of the effective ladder. An empty @@ -128,11 +137,14 @@ export interface EffectiveModelExportMetadata { } /** - * The per-row evidence the capability booleans are derived from. Only positive, canonical - * signals — no independent provider fact tables, no name-based inference. + * Canonical explicit capabilities and positive evidence for otherwise unknown booleans. + * No independent provider fact tables or name-based inference. */ export interface ExportCapabilityEvidence { native?: boolean; + supportsReasoning?: boolean; + supportsThinkingToggle?: boolean; + supportsReasoningEffort?: boolean; reasoningEfforts?: readonly string[]; supportsReasoningSummaries?: boolean; /** Normalized upstream capability names a catalog row carried (e.g. `"tools"`). */ @@ -156,8 +168,7 @@ export function knownToolsSupport(evidence: ExportCapabilityEvidence): boolean | } /** - * Reasoning support a canonical source positively asserts, or undefined when nothing is - * known. + * Explicit reasoning support, or positive canonical evidence when no boolean is declared. * * Never `false` from a missing or empty effort ladder: an empty ladder says "no ADJUSTABLE * effort", which is not the claim "cannot reason" — a model can reason at a fixed depth with @@ -167,8 +178,10 @@ export function knownToolsSupport(evidence: ExportCapabilityEvidence): boolean | * nothing about whether the model reasons, so it is not positive evidence on its own. */ export function knownReasoningSupport(evidence: ExportCapabilityEvidence): boolean | undefined { + if (typeof evidence.supportsReasoning === "boolean") return evidence.supportsReasoning; + if (evidence.supportsThinkingToggle === true || evidence.supportsReasoningEffort === true) return true; if (evidence.supportsReasoningSummaries === true) return true; - if (evidence.reasoningEfforts !== undefined + if (evidence.supportsReasoningEffort !== false && evidence.reasoningEfforts !== undefined && canonicalizeReasoningEfforts(evidence.reasoningEfforts).some(effort => effort !== "none")) { return true; } diff --git a/src/clients/config-export/reasoning-metadata.ts b/src/clients/config-export/reasoning-metadata.ts index 642cdec29f3..2d85324485d 100644 --- a/src/clients/config-export/reasoning-metadata.ts +++ b/src/clients/config-export/reasoning-metadata.ts @@ -1,16 +1,55 @@ import { canonicalizeReasoningEfforts, isDeclaredReasoningEffort } from "../../reasoning-effort"; import { knownReasoningSupport, type OpencodeCatalogModel } from "./contracts"; -/** Unknown and explicitly empty ladders have different client-side meanings. */ -export function exportReasoningEfforts(model: OpencodeCatalogModel): string[] | undefined { - return model.reasoningEfforts === undefined ? undefined : canonicalizeReasoningEfforts(model.reasoningEfforts); +type ReasoningMetadata = Pick; + +/** Shared row projection; an effective projection owns the default even when it omits it. */ +export function resolveExportReasoningAxes(effective: ReasoningMetadata | undefined, row: ReasoningMetadata) { + const supportsThinkingToggle = effective?.supportsThinkingToggle ?? row.supportsThinkingToggle; + const supportsReasoningEffort = effective?.supportsReasoningEffort ?? row.supportsReasoningEffort; + const defaultThinkingEnabled = effective?.defaultThinkingEnabled ?? row.defaultThinkingEnabled; + const declaredEfforts = effective?.reasoningEfforts ?? row.reasoningEfforts; + const effortDisabled = supportsReasoningEffort === false + || (effective?.supportsReasoning ?? row.supportsReasoning) === false; + return { + supportsThinkingToggle, + supportsReasoningEffort, + defaultThinkingEnabled, + reasoningEfforts: declaredEfforts === undefined ? undefined : effortDisabled ? [] : [...declaredEfforts], + // Falling back through an existing projection would revive a synthetic picker default. + // Older rows without a projection retain their own declared default. + defaultReasoningEffort: effortDisabled ? undefined : effective !== undefined + ? effective.defaultReasoningEffort : row.defaultReasoningEffort, + }; } -/** Carry an authoritative default, never invent one or revive a cleared ladder. */ -export function exportDefaultReasoningEffort(model: OpencodeCatalogModel): string | undefined { +/** Effort-enum client compatibility only; never write these choices back into model facts. */ +export function exportReasoningEfforts(model: ReasoningMetadata): string[] | undefined { + if (model.supportsReasoning === false) return []; + const efforts = model.reasoningEfforts === undefined ? undefined : canonicalizeReasoningEfforts(model.reasoningEfforts); + if (model.supportsReasoningEffort === false) { + return model.supportsThinkingToggle === true ? ["none", "high"] : []; + } + if (model.supportsThinkingToggle === true) { + const strengths = efforts?.filter(effort => effort !== "none") ?? []; + return strengths.length ? ["none", ...strengths] : ["none", "high"]; + } + return model.supportsThinkingToggle === false ? efforts?.filter(effort => effort !== "none") : efforts; +} + +/** Encode the declared effort/default-thinking choice without inventing a strength. */ +export function exportDefaultReasoningEffort(model: ReasoningMetadata): string | undefined { + const efforts = exportReasoningEfforts(model); + if (model.supportsThinkingToggle === true && model.supportsReasoning !== false) { + if (model.defaultThinkingEnabled === false) return "none"; + const toggleOnly = model.supportsReasoningEffort === false + || !model.reasoningEfforts?.some(effort => isDeclaredReasoningEffort(effort) && effort !== "none"); + if (toggleOnly) return model.defaultThinkingEnabled === true ? "high" : undefined; + if (model.defaultThinkingEnabled === true && model.defaultReasoningEffort === "none") return undefined; + } const effort = model.defaultReasoningEffort; if (typeof effort !== "string" || !isDeclaredReasoningEffort(effort)) return undefined; - const efforts = exportReasoningEfforts(model); return efforts === undefined || efforts.includes(effort) ? effort : undefined; } @@ -34,9 +73,17 @@ export function legacyReasoningMetadata(model: OpencodeCatalogModel): { const suppressed: Record = Object.fromEntries( ["none", "minimal", "low", "medium", "high", "xhigh", "max"].map(id => [id, { disabled: true }]), ); - const variants = efforts?.length + let variants = efforts?.length ? Object.fromEntries(efforts.map(effort => [effort, { reasoningEffort: effort }])) - : reasoning === true ? suppressed : undefined; + : reasoning === true || model.supportsReasoning === false || model.supportsReasoningEffort === false ? suppressed : undefined; + // Constrained controls replace generated choices; a negative toggle by itself does not + // declare the still-unknown effort axis unsupported. + if (model.supportsThinkingToggle === true || model.supportsReasoningEffort !== undefined + || model.supportsReasoning === false || (model.supportsThinkingToggle === false && efforts !== undefined)) { + variants = { ...suppressed, ultra: { disabled: true }, ...variants }; + } else if (model.supportsThinkingToggle === false) { + variants = { none: { disabled: true }, ...variants }; + } return { ...(typeof reasoning === "boolean" ? { reasoning } : {}), ...(defaultEffort !== undefined ? { options: { reasoningEffort: defaultEffort } } : {}), diff --git a/src/clients/reasoning-controls.ts b/src/clients/reasoning-controls.ts new file mode 100644 index 00000000000..81c1781516e --- /dev/null +++ b/src/clients/reasoning-controls.ts @@ -0,0 +1,49 @@ +import type { OcxProviderConfig } from "../types"; +import { isDeclaredReasoningEffort, isReasoningEffortOmitted, modelRecordValue, reasoningEffortMapFor } from "../reasoning-effort"; +import { resolveReasoningCapabilities } from "../providers/reasoning-capabilities"; + +function mappedClientEffort(provider: OcxProviderConfig, modelId: string, requested: string | undefined): string | undefined { + if (requested === undefined || !isDeclaredReasoningEffort(requested)) return undefined; + const key = requested === "ultra" ? "max" : requested; + const map = reasoningEffortMapFor(provider, modelId); + return map && Object.hasOwn(map, key) ? map[key] : requested; +} + +/** Decode an effort-only client's switch without turning it into a model strength. */ +export function resolveThinkingEnabled( + provider: OcxProviderConfig, + modelId: string, + options: { thinkingEnabled?: boolean; reasoning?: string }, +): boolean | undefined { + const capabilities = resolveReasoningCapabilities(provider, modelId); + if (capabilities.supportsReasoning === false || capabilities.supportsThinkingToggle !== true) return undefined; + if (typeof options.thinkingEnabled === "boolean") return options.thinkingEnabled; + // Effort-enum clients select a switch and, when supported, a real positive strength. + // An explicit boolean above remains independent and takes precedence over that shorthand. + const effort = options.reasoning ?? (capabilities.supportsReasoningEffort === false + ? modelRecordValue(provider.modelDefaultReasoningEfforts, modelId) : undefined); + const value = mappedClientEffort(provider, modelId, effort); + if (value !== undefined) { + // Legacy adaptive wire policy cannot be represented by a boolean; leave its serializer in charge. + if (isReasoningEffortOmitted(value) || value === "adaptive") return undefined; + return value !== "none" && value !== "disabled"; + } + return capabilities.defaultThinkingEnabled; +} + +/** Resolve client aliases before strength-only policy can strip or clamp them. */ +export function normalizeThinkingOptions(provider: OcxProviderConfig, modelId: string, options: { thinkingEnabled?: boolean; reasoning?: string }): void { + const capabilities = resolveReasoningCapabilities(provider, modelId); + if (capabilities.supportsReasoning === false) { + delete options.thinkingEnabled; + delete options.reasoning; + return; + } + const enabled = resolveThinkingEnabled(provider, modelId, options); + if (enabled !== undefined) options.thinkingEnabled = enabled; + else if (capabilities.supportsThinkingToggle === false) delete options.thinkingEnabled; + const mapped = mappedClientEffort(provider, modelId, options.reasoning); + const preserveWirePolicy = capabilities.supportsThinkingToggle === true + && (mapped === "adaptive" || isReasoningEffortOmitted(mapped)); + if (capabilities.supportsReasoningEffort === false && !preserveWirePolicy) delete options.reasoning; +} diff --git a/src/codex/catalog/effort.ts b/src/codex/catalog/effort.ts index 1af507ceebc..dc86f6d11fd 100644 --- a/src/codex/catalog/effort.ts +++ b/src/codex/catalog/effort.ts @@ -9,6 +9,7 @@ import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, readR import { clearModelCache, DEFAULT_MODEL_CACHE_TTL_MS, getFreshCached, getStaleCached, isModelsFetchCoolingDown, markModelsFetchFailure, setCached } from "../model-cache"; import { buildModelsRequest, resolveModelsAuthToken } from "../../oauth"; import type { OcxConfig, OcxProviderConfig } from "../../types"; +import { exportDefaultReasoningEffort, exportReasoningEfforts } from "../../clients/config-export/reasoning-metadata"; import { modelInList } from "../../types"; import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts, type CodexReasoningLevel } from "../../reasoning-effort"; import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider } from "../../generated/model-metadata"; @@ -139,6 +140,13 @@ export const ROUTED_REASONING_LEVELS = [...CODEX_REASONING_LEVELS]; export function applyCatalogModelMetadata(entry: RawEntry, model?: CatalogModel): void { if (!model) return; + applyClientReasoningCapabilities(entry, model); + if (model.supportsThinkingToggle === true && model.supportsReasoningEffort === false + && Array.isArray(entry.supported_reasoning_levels)) { + entry.supported_reasoning_levels = entry.supported_reasoning_levels.map(level => ({ + ...level, description: level.effort === "none" ? "Thinking off" : "Thinking on", + })); + } // This marker survives strict catalog normalization and lets sync distinguish a stale // bare combo alias from a genuine native model row. if (model.provider === COMBO_NAMESPACE) entry.owned_by = model.owned_by ?? COMBO_NAMESPACE; @@ -254,6 +262,26 @@ function stampCapabilityProvenance(entry: RawEntry, model: CatalogModel): void { }; } +/** Project explicit independent capabilities only at Codex's effort-enum boundary. */ +function applyClientReasoningCapabilities(entry: RawEntry, model: CatalogModel): void { + // Positive effort support can be inferred from a legacy ladder. It does not classify + // independent controls or opt that model out of the existing max/ultra synthesis. + if (model.supportsThinkingToggle === undefined && model.supportsReasoningEffort !== false + && model.supportsReasoning !== false) return; + const efforts = exportReasoningEfforts(model); + if (efforts === undefined && model.supportsReasoningEffort === undefined) { + // A negative toggle declaration says nothing about an unknown effort capability. + const legacy = catalogEntryEfforts(entry).filter(effort => effort !== "none"); + applyReasoningLevels(entry, legacy, typeof entry.default_reasoning_level === "string" ? entry.default_reasoning_level : undefined, true); + return; + } + const declaredDefault = exportDefaultReasoningEffort(model); + applyReasoningLevels(entry, efforts ?? [], declaredDefault, true); + if (model.supportsThinkingToggle === true && declaredDefault === undefined) { + delete entry.default_reasoning_level; + } +} + export function applyReasoningLevels( entry: RawEntry, effortsOverride?: string[], diff --git a/src/codex/catalog/model-hints.ts b/src/codex/catalog/model-hints.ts index 15b1a09e900..073bf5ddef6 100644 --- a/src/codex/catalog/model-hints.ts +++ b/src/codex/catalog/model-hints.ts @@ -34,6 +34,7 @@ import { } from "../../oauth"; import type { OcxConfig, OcxProviderConfig } from "../../types"; import { modelInList } from "../../types"; +import { declaredModelCapabilities, REASONING_CAPABILITY_FIELDS } from "../../providers/reasoning-capabilities"; import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort"; import { isModelVisionSidecarConsumer } from "../../vision/eligibility"; import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider, type ModelMetadata } from "../../generated/model-metadata"; @@ -260,7 +261,17 @@ export function withVisionSidecarInput( ): CatalogModel { if (!enabled || !provider || !isModelVisionSidecarConsumer(provider, model.id)) return model; const modalities = model.inputModalities ?? ["text"]; - return modalities.includes("image") ? model : { ...model, inputModalities: [...modalities, "image"] }; + return modalities.includes("image") ? model + : copyReasoningHintSource(model, { ...model, inputModalities: [...modalities, "image"] }); +} + +const reasoningHintSources = new WeakMap>(); + +/** Forward discovery provenance only across transparent clones, never custom reasoning overrides. */ +export function copyReasoningHintSource(source: CatalogModel, clone: CatalogModel): CatalogModel { + const reasoningSource = reasoningHintSources.get(source); + if (reasoningSource) reasoningHintSources.set(clone, reasoningSource); + return clone; } /** Reapply configured limits and captured wire capabilities without trusting stale discovery hints. */ @@ -302,10 +313,25 @@ export function applyProviderConfigHints( const resolvedModalities = staticPolicy.model.inputModalities ?? model.inputModalities ?? (azureMetadata?.input ? modelInputModalities({ id: model.id, input_modalities: azureMetadata.input }, undefined) : undefined); const inputModalities = resolvedModalities ? [...resolvedModalities] : undefined; - const reasoningEfforts = configuredReasoningEfforts(prov, model.id); + const reasoningSource = reasoningHintSources.get(model) ?? model; + const declaredCapabilities = declaredModelCapabilities(prov.modelCapabilities, model.id); + const reasoningCapabilities: Partial = {}; + for (const field of REASONING_CAPABILITY_FIELDS) { + const value = modelInList(prov.noReasoningModels, model.id) ? staticPolicy.model[field] + : declaredCapabilities?.[field] ?? reasoningSource[field] ?? staticPolicy.model[field]; + if (typeof value === "boolean") reasoningCapabilities[field] = value; + } + if (reasoningCapabilities.supportsReasoning === false) { + reasoningCapabilities.supportsThinkingToggle = false; + reasoningCapabilities.supportsReasoningEffort = false; + reasoningCapabilities.defaultThinkingEnabled = false; + } + const reasoningEfforts = reasoningCapabilities.supportsReasoningEffort === false ? [] + : configuredReasoningEfforts(prov, model.id) ?? reasoningSource.reasoningEfforts; const suppressSyntheticMax = modelRecordValue(prov.modelSuppressSyntheticMax, model.id) ?? (model.antigravityEffortWireModelIds !== undefined); - const defaultReasoningEffort = staticPolicy.model.defaultReasoningEffort ?? model.defaultReasoningEffort; + const defaultReasoningEffort = reasoningCapabilities.supportsReasoningEffort === false ? undefined + : staticPolicy.model.defaultReasoningEffort ?? reasoningSource.defaultReasoningEffort; const supportsReasoningSummaries = staticPolicy.model.supportsReasoningSummaries; const supportsVerbosity = staticPolicy.model.supportsVerbosity; const fastPolicy = fastPolicyForModel(prov, model.id, name); @@ -323,6 +349,12 @@ export function applyProviderConfigHints( fastTierDescription: _staleFastTierDescription, providerAlias: _staleProviderAlias, suppressSyntheticMax: _staleSuppressSyntheticMax, + supportsReasoning: _staleSupportsReasoning, + supportsThinkingToggle: _staleThinkingToggle, + supportsReasoningEffort: _staleReasoningEffort, + defaultThinkingEnabled: _staleDefaultThinking, + reasoningEfforts: _staleReasoningEfforts, + defaultReasoningEffort: _staleDefaultEffort, ...modelWithoutServiceTier } = model; // 已发现窗口只允许被配置值压低;缺窗口时,已开的 Context cap 就是实际窗口。 @@ -356,6 +388,7 @@ export function applyProviderConfigHints( } : {}), ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}), + ...reasoningCapabilities, ...(defaultReasoningEffort ? { defaultReasoningEffort } : {}), ...(typeof supportsReasoningSummaries === "boolean" ? { supportsReasoningSummaries } : {}), ...(typeof supportsVerbosity === "boolean" ? { supportsVerbosity } : {}), @@ -389,8 +422,7 @@ export function applyProviderConfigHints( : withCap; const softCandidates = [model.autoCompactTokenLimit, configuredAutoCompact] .filter((value): value is number => typeof value === "number" && value > 0); - if (contextWindow === undefined || softCandidates.length === 0) return withHardBounds; - return { + const result = contextWindow === undefined || softCandidates.length === 0 ? withHardBounds : { ...withHardBounds, autoCompactTokenLimit: clampAutoCompactTokenLimit( contextWindow, @@ -398,6 +430,10 @@ export function applyProviderConfigHints( Math.min(...softCandidates), ), }; + reasoningHintSources.set(result, Object.fromEntries([ + ...REASONING_CAPABILITY_FIELDS, "reasoningEfforts", "defaultReasoningEffort", + ].map(field => [field, reasoningSource[field as keyof CatalogModel]]))); + return result; } /** Project a configured row, optionally deferring sidecar attachment metadata until publication. */ @@ -697,6 +733,15 @@ export function discoveredPricingStatus(item: ProviderModelsApiItem): "free" | " export function catalogHintsFromModelsApiItem(providerName: string, item: ProviderModelsApiItem): Partial { const metadata = plainRecord(item.metadata); const capabilityRecord = plainRecord(metadata?.capabilities) ?? plainRecord(item.capabilities); + const reasoningCapabilities: Partial = {}; + for (const [wire, field] of [ + ["supports_reasoning", "supportsReasoning"], + ["supports_thinking_toggle", "supportsThinkingToggle"], + ["supports_reasoning_effort", "supportsReasoningEffort"], + ["default_thinking_enabled", "defaultThinkingEnabled"], + ] as const) { + if (typeof capabilityRecord?.[wire] === "boolean") reasoningCapabilities[field] = capabilityRecord[wire]; + } const limits = plainRecord(metadata?.limits); const capabilityLimits = plainRecord(plainRecord(item.capabilities)?.limits); const contextWindow = @@ -748,7 +793,8 @@ export function catalogHintsFromModelsApiItem(providerName: string, item: Provid ?? item.reasoning_efforts ?? reasoningParameters?.efforts; const listedReasoningEfforts = normalizedStringList(rawReasoningEfforts, 8, 24); - const reasoningEfforts = listedReasoningEfforts + const reasoningEfforts = reasoningCapabilities.supportsReasoning === false || reasoningCapabilities.supportsReasoningEffort === false ? [] + : listedReasoningEfforts ? sanitizeCodexReasoningEfforts(listedReasoningEfforts) : typeof rawReasoningEfforts === "boolean" ? (rawReasoningEfforts @@ -766,6 +812,7 @@ export function catalogHintsFromModelsApiItem(providerName: string, item: Provid ...(contextWindow && contextWindow > 0 ? { contextWindow } : {}), ...(maxInputTokens && maxInputTokens > 0 ? { maxInputTokens } : {}), ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}), + ...reasoningCapabilities, ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}), ...(inputModalities ? { inputModalities } : {}), ...(capabilities ? { capabilities } : {}), diff --git a/src/codex/catalog/model-visibility.ts b/src/codex/catalog/model-visibility.ts index c95b8f2d550..51059fca1cb 100644 --- a/src/codex/catalog/model-visibility.ts +++ b/src/codex/catalog/model-visibility.ts @@ -102,7 +102,7 @@ import type { CatalogSourceEvidence, CatalogTrustedOpenAiApiPolicySnapshot, } from "../convergence-types"; -import { CALLABLE_CONFIGURED_COMPATIBILITY_MODELS, applyProviderConfigHints } from "./model-hints"; +import { CALLABLE_CONFIGURED_COMPATIBILITY_MODELS, applyProviderConfigHints, copyReasoningHintSource } from "./model-hints"; const DATED_VARIANT_YYYYMMDD = /^(\d{4})(\d{2})(\d{2})$/; const DATED_VARIANT_YYMMDD = /^(2\d)(\d{2})(\d{2})$/; @@ -250,7 +250,8 @@ export function mergeConfiguredModelsIntoLiveCatalog(opts: { if (present.has(candidate.id)) continue; const dated = out.find(live => isDatedVariantId(live.id, candidate.id)); if (dated) { - out.push(applyProviderConfigHints(name, prov, { ...dated, id: candidate.id }, contextCap, metadataModelIdCaseFold, undefined, metadataConfigDir, includeVisionSidecar)); + const alias = copyReasoningHintSource(dated, { ...dated, id: candidate.id }); + out.push(applyProviderConfigHints(name, prov, alias, contextCap, metadataModelIdCaseFold, undefined, metadataConfigDir, includeVisionSidecar)); present.add(candidate.id); continue; } diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index af119b37a2a..7b9bce865d5 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -104,6 +104,10 @@ export interface CatalogModel { description?: string; owned_by?: string; reasoningEfforts?: string[]; + supportsReasoning?: boolean; + supportsThinkingToggle?: boolean; + supportsReasoningEffort?: boolean; + defaultThinkingEnabled?: boolean; /** Exact CCA family evidence, retained through the in-memory discovery cache. */ antigravityEffortWireModelIds?: Partial>; /** Suppress only catalog synthesis of a missing max rung; provider-declared max survives. */ diff --git a/src/codex/catalog/provider-models.ts b/src/codex/catalog/provider-models.ts index 58c7a99e88f..9af396a13ae 100644 --- a/src/codex/catalog/provider-models.ts +++ b/src/codex/catalog/provider-models.ts @@ -210,11 +210,10 @@ export async function fetchProviderModelsWithAuth( ...(prov.models ?? []), ...(prov.retainModels ?? []), ])]; - const configured: CatalogModel[] = configuredIds.map(id => ({ - id, - provider: name, - ...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false), - })); + const configured: CatalogModel[] = configuredIds.map(id => applyProviderConfigHints( + name, prov, { id, provider: name }, contextCap, metadataModelIdCaseFold, + captured.effectiveAlias, metadataConfigDir, false, + )); const withConfiguredRetention = ( models: CatalogModel[], options?: { retainComboTargets?: boolean; warnDrops?: boolean }, diff --git a/src/codex/catalog/routed-gather.ts b/src/codex/catalog/routed-gather.ts index 23fafa4778c..76e7fe75f75 100644 --- a/src/codex/catalog/routed-gather.ts +++ b/src/codex/catalog/routed-gather.ts @@ -102,7 +102,7 @@ import type { CatalogTrustedOpenAiApiPolicySnapshot, } from "../convergence-types"; import type { CatalogGatherProviderAuthOutcome, CatalogGatherProviderModelOutcome, GatherFlightCapture, ModelsAuthResolverFactory } from "./gather-capture"; -import { applyProviderConfigHints, configuredAutoCompactTokenLimit, configuredMaxInputTokens, configuredModelDisplayName, configuredReasoningSummarySupport, modelInputModalities, routedMaxOutputTokens, withVisionSidecarInput } from "./model-hints"; +import { applyProviderConfigHints, configuredAutoCompactTokenLimit, configuredMaxInputTokens, configuredModelDisplayName, configuredReasoningSummarySupport, copyReasoningHintSource, modelInputModalities, routedMaxOutputTokens, withVisionSidecarInput } from "./model-hints"; import { resolveComboCatalogMember } from "./combo-member"; import { captureGatherFlight, captureTrustedOpenAiApiPolicy, gatherFlightKey, keyedGatherBytesIdentity, withCanonicalOpenAiForwardAuthDefault } from "./gather-capture"; import { fetchProviderModelsWithAuth, observedModelsAuthResolver, refreshingModelsAuthResolver } from "./provider-models"; @@ -820,8 +820,8 @@ async function gatherRoutedModelsUncached( ? config.combos?.[model.id]?.targets ?? [] : [{ provider: model.provider }]; const inactive = quotaInactiveReason(config, targets); - const named = displayName && !model.displayName ? { ...model, displayName } : model; - return inactive ? { ...named, quotaInactiveReason: inactive } : named; + const named = displayName && !model.displayName ? copyReasoningHintSource(model, { ...model, displayName }) : model; + return inactive ? copyReasoningHintSource(named, { ...named, quotaInactiveReason: inactive }) : named; }), comboOmissions: localOmissions, providerAuthOutcomes: localProviderAuthOutcomes, @@ -968,7 +968,7 @@ export function augmentRoutedModelsWithMetadata( : undefined; const liveWindow = typeof existing.contextWindow === "number" && existing.contextWindow > 0; if (liveWindow || publishedWindow === undefined) continue; - const seeded: CatalogModel = { ...existing, contextWindow: publishedWindow }; + const seeded = copyReasoningHintSource(existing, { ...existing, contextWindow: publishedWindow }); out[existingIndex] = providers?.[provider] ? applyProviderConfigHints( provider, @@ -992,21 +992,18 @@ export function augmentRoutedModelsWithMetadata( ...(typeof meta.maxTokens === "number" && meta.maxTokens > 0 ? { maxOutputTokens: meta.maxTokens } : {}), ...(Array.isArray(meta.input) && meta.input.length > 0 ? { inputModalities: [...meta.input] } : {}), }; - out.push({ - ...model, - ...(providers?.[provider] - ? applyProviderConfigHints( - provider, - providers[provider], - model, - contextCap, - metadataModelIdCaseFoldByProvider?.get(provider), - undefined, - metadataConfigDirByProvider?.get(provider), - includeVisionSidecar, - ) - : {}), - }); + out.push(providers?.[provider] + ? applyProviderConfigHints( + provider, + providers[provider], + model, + contextCap, + metadataModelIdCaseFoldByProvider?.get(provider), + undefined, + metadataConfigDirByProvider?.get(provider), + includeVisionSidecar, + ) + : model); } } return out; diff --git a/src/config/provider-validation.ts b/src/config/provider-validation.ts index 3a6eb09e6f6..598e7d6db40 100644 --- a/src/config/provider-validation.ts +++ b/src/config/provider-validation.ts @@ -470,7 +470,7 @@ export function modelCapabilitiesConfigError(value: unknown, allowTombstones = f if (allowTombstones && row === null) continue; if (!capabilityRecord(row)) return "modelCapabilities rows must be plain objects"; for (const [axis, declaration] of Object.entries(row)) { - if (!["inputModalities", "contextTier", "video", "deepseekImageFiles"].includes(axis)) return "modelCapabilities contains an unknown axis"; + if (!["inputModalities", "contextTier", "video", "deepseekImageFiles", "supportsReasoning", "supportsThinkingToggle", "supportsReasoningEffort", "defaultThinkingEnabled"].includes(axis)) return "modelCapabilities contains an unknown axis"; if (allowTombstones && declaration === null) continue; if (axis === "inputModalities") { if (!Array.isArray(declaration) || declaration.length === 0 @@ -479,8 +479,8 @@ export function modelCapabilitiesConfigError(value: unknown, allowTombstones = f } } else if (axis === "contextTier") { if (declaration !== "default" && declaration !== "long_context") return "modelCapabilities contextTier must be default or long_context"; - } else if (axis === "deepseekImageFiles") { - if (typeof declaration !== "boolean") return "modelCapabilities deepseekImageFiles must be a boolean"; + } else if (["deepseekImageFiles", "supportsReasoning", "supportsThinkingToggle", "supportsReasoningEffort", "defaultThinkingEnabled"].includes(axis)) { + if (typeof declaration !== "boolean") return `modelCapabilities ${axis} must be a boolean`; } else { if (!capabilityRecord(declaration) || Object.keys(declaration).some(key => key !== "processing")) { return "modelCapabilities video must be a plain object containing only processing"; @@ -513,6 +513,9 @@ export function mergeModelCapabilities( } else if (axis === "deepseekImageFiles") { if (value === null) delete row.deepseekImageFiles; else row.deepseekImageFiles = value as boolean; + } else if (axis === "supportsReasoning" || axis === "supportsThinkingToggle" || axis === "supportsReasoningEffort" || axis === "defaultThinkingEnabled") { + if (value === null) delete row[axis]; + else row[axis] = value as boolean; } else if (axis === "video") { if (value === null) delete row.video; else { @@ -540,7 +543,7 @@ export function sanitizeModelCapabilitiesForLoad(value: unknown): Record = {}; - for (const axis of ["inputModalities", "contextTier", "video", "deepseekImageFiles"] as const) { + for (const axis of ["inputModalities", "contextTier", "video", "deepseekImageFiles", "supportsReasoning", "supportsThinkingToggle", "supportsReasoningEffort", "defaultThinkingEnabled"] as const) { if (!Object.hasOwn(raw, axis)) continue; const declaration = raw[axis]; if (modelCapabilitiesConfigError({ [id]: { [axis]: declaration } }) === null) row[axis] = declaration; diff --git a/src/providers/derive.ts b/src/providers/derive.ts index 59090d164ac..7f2c5a60367 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -4,6 +4,7 @@ import { cloneFastWire } from "./fastwire"; import { isAzureModelMetadataDestination, publishedAzureModelMetadata, type AzureModelMetadata } from "./azure-model-metadata"; import { resolveModelPolicy } from "./resolved-model-policy"; import { mapFill, nestedMapFill, stableUnion } from "./resolved-model-policy-merge"; +import { mergeReasoningModelCapabilities } from "./reasoning-capabilities"; import { PROVIDER_REGISTRY, registryEntryForProviderDestination, @@ -31,6 +32,7 @@ export interface DerivedKeyLoginProvider { contextWindow?: number; modelContextWindows?: Record; modelInputModalities?: Record; + modelCapabilities?: OcxProviderConfig["modelCapabilities"]; modelMaxInputTokens?: Record; defaultMaxOutputTokens?: number; modelMaxOutputTokens?: Record; @@ -263,6 +265,7 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon ...(staticPolicy.modelContextWindows ? { modelContextWindows: { ...staticPolicy.modelContextWindows } } : {}), ...(staticPolicy.modelDisplayNames ? { modelDisplayNames: { ...staticPolicy.modelDisplayNames } } : {}), ...(staticPolicy.modelInputModalities ? { modelInputModalities: cloneRecordOfArrays(staticPolicy.modelInputModalities) } : {}), + ...(staticPolicy.modelCapabilities ? { modelCapabilities: structuredClone(staticPolicy.modelCapabilities) } : {}), ...(staticPolicy.modelMaxInputTokens ? { modelMaxInputTokens: { ...staticPolicy.modelMaxInputTokens } } : {}), ...(entry.defaultMaxOutputTokens !== undefined ? { defaultMaxOutputTokens: entry.defaultMaxOutputTokens } : {}), ...(staticPolicy.modelMaxOutputTokens ? { modelMaxOutputTokens: { ...staticPolicy.modelMaxOutputTokens } } : {}), @@ -340,6 +343,7 @@ export function deriveKeyLoginMap(): Record { ...(entry.modelMaxOutputTokens ? { modelMaxOutputTokens: { ...entry.modelMaxOutputTokens } } : {}), ...(entry.reasoningEfforts ? { reasoningEfforts: [...entry.reasoningEfforts] } : {}), ...(entry.modelReasoningEfforts ? { modelReasoningEfforts: cloneRecordOfArrays(entry.modelReasoningEfforts) } : {}), + ...(entry.modelCapabilities ? { modelCapabilities: structuredClone(entry.modelCapabilities) } : {}), ...(entry.modelDefaultReasoningEfforts ? { modelDefaultReasoningEfforts: { ...entry.modelDefaultReasoningEfforts } } : {}), ...(entry.reasoningEffortMap ? { reasoningEffortMap: { ...entry.reasoningEffortMap } } : {}), ...(entry.modelReasoningEffortMap ? { modelReasoningEffortMap: cloneNestedRecord(entry.modelReasoningEffortMap) } : {}), @@ -484,6 +488,8 @@ function applyVerbosityDefaults(prov: OcxProviderConfig, entry: ProviderRegistry */ function enrichReasoningMetadataByDestination(prov: OcxProviderConfig): void { const destination = registryEntryForProviderDestination(prov); + const capabilities = mergeReasoningModelCapabilities(destination?.modelCapabilities, prov.modelCapabilities); + if (capabilities) prov.modelCapabilities = capabilities; applyReasoningSummaryDefaults(prov, destination?.modelSupportsReasoningSummaries); if (destination?.modelDefaultReasoningEfforts) { prov.modelDefaultReasoningEfforts = mapFill(destination.modelDefaultReasoningEfforts, prov.modelDefaultReasoningEfforts)[0]; @@ -500,6 +506,9 @@ function enrichReasoningMetadataByDestination(prov: OcxProviderConfig): void { if (destination?.modelReasoningEfforts) { prov.modelReasoningEfforts = fillRecordOfArrays(destination.modelReasoningEfforts, prov.modelReasoningEfforts); } + if (destination?.modelDefaultReasoningEfforts) { + [prov.modelDefaultReasoningEfforts] = mapFill(destination.modelDefaultReasoningEfforts, prov.modelDefaultReasoningEfforts); + } if (prov.reasoningEfforts === undefined && destination?.reasoningEfforts !== undefined) { prov.reasoningEfforts = [...destination.reasoningEfforts]; } @@ -568,6 +577,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig // (mapFill in src/providers/resolved-model-policy-merge.ts), so the wire honored the effort while /v1/models and // every client export showed no effort control at all. if (resolvedStatic.modelReasoningEfforts) prov.modelReasoningEfforts = cloneRecordOfArrays(resolvedStatic.modelReasoningEfforts); + if (resolvedStatic.modelCapabilities) prov.modelCapabilities = structuredClone(resolvedStatic.modelCapabilities); if (!prov.modelDefaultReasoningEfforts && seed.modelDefaultReasoningEfforts) prov.modelDefaultReasoningEfforts = { ...seed.modelDefaultReasoningEfforts }; if (!prov.reasoningEffortMap && seed.reasoningEffortMap) prov.reasoningEffortMap = { ...seed.reasoningEffortMap }; if (!prov.modelReasoningEffortMap && seed.modelReasoningEffortMap) prov.modelReasoningEffortMap = cloneNestedRecord(seed.modelReasoningEffortMap); diff --git a/src/providers/mimo-thinking.ts b/src/providers/mimo-thinking.ts new file mode 100644 index 00000000000..6e3a5095f11 --- /dev/null +++ b/src/providers/mimo-thinking.ts @@ -0,0 +1,12 @@ +import type { ModelCapabilities } from "../types"; + +// Public MiMo V2.6 exposes an on/off switch, not distinct reasoning strengths. +export const MIMO_THINKING_MODELS = ["mimo-v2.6-pro", "mimo-v2.6-flash", "mimo-v2.6-pro-ultraspeed"] as const; +export const MIMO_THINKING_CAPABILITIES: Record = Object.fromEntries( + MIMO_THINKING_MODELS.map(id => [id, { + supportsReasoning: true, + supportsThinkingToggle: true, + supportsReasoningEffort: false, + defaultThinkingEnabled: true, + }]), +); diff --git a/src/providers/reasoning-capabilities.ts b/src/providers/reasoning-capabilities.ts new file mode 100644 index 00000000000..54e5cc18d7f --- /dev/null +++ b/src/providers/reasoning-capabilities.ts @@ -0,0 +1,76 @@ +import type { ModelCapabilities, OcxProviderConfig, ReasoningCapabilities } from "../types"; +import { modelInList } from "../types"; +import { registryEntryForProviderDestination } from "./registry"; +import { legacyModelValue, mapFill, nestedMapFill } from "./resolved-model-policy-merge"; + +export const REASONING_CAPABILITY_FIELDS = [ + "supportsReasoning", "supportsThinkingToggle", "supportsReasoningEffort", "defaultThinkingEnabled", +] as const; + +/** Case-equal model declarations merge per axis; unrelated axes retain registry defaults. */ +export function mergeReasoningModelCapabilities( + registry: Record | undefined, + explicit: Record | undefined, +): Record | undefined { + if (!registry && !explicit) return undefined; + const merged: Record = {}; + const keys = new Map(); + for (const rows of [registry, explicit]) { + for (const [id, row] of Object.entries(rows ?? {})) { + const key = keys.get(id.toLowerCase()) ?? id; + keys.set(id.toLowerCase(), key); + merged[key] = { ...merged[key], ...structuredClone(row) }; + } + } + return merged; +} + +/** Match a declared model case-insensitively, without inheriting suffix or family aliases. */ +export function declaredModelCapabilities(rows: Readonly> | undefined, modelId: string): ModelCapabilities | undefined { + return rows?.[modelId] ?? Object.entries(rows ?? {}).find(([id]) => id.toLowerCase() === modelId.toLowerCase())?.[1]; +} + +/** Captured-policy variant: never reads the live registry after admission. */ +export function reasoningCapabilitiesFromConfig(provider: Readonly, modelId: string): ReasoningCapabilities { + if (modelInList(provider.noReasoningModels, modelId)) { + return { supportsReasoning: false, supportsThinkingToggle: false, supportsReasoningEffort: false, defaultThinkingEnabled: false }; + } + const declared = declaredModelCapabilities(provider.modelCapabilities, modelId); + const result: ReasoningCapabilities = {}; + for (const field of REASONING_CAPABILITY_FIELDS) { + if (typeof declared?.[field] === "boolean") result[field] = declared[field]; + } + const efforts = legacyModelValue(provider.modelReasoningEfforts, modelId) ?? provider.reasoningEfforts; + const effortMap = legacyModelValue(provider.modelReasoningEffortMap, modelId) ?? provider.reasoningEffortMap; + const effectiveEfforts = efforts?.filter(effort => effort !== "none").map(effort => effortMap?.[effort] ?? effort); + const strengths = ["minimal", "low", "medium", "high", "xhigh", "max", "ultra"]; + if (result.supportsReasoningEffort === undefined && effectiveEfforts?.some(effort => strengths.includes(effort))) { + result.supportsReasoningEffort = true; + } + if (result.supportsReasoning === undefined && (result.supportsThinkingToggle === true + || result.supportsReasoningEffort === true || effectiveEfforts?.some(effort => strengths.includes(effort) || effort === "enabled" || effort === "adaptive") + || modelInList(provider.thinkingBudgetModels, modelId))) { + result.supportsReasoning = true; + } + if (result.supportsReasoning === false) { + result.supportsThinkingToggle = false; + result.supportsReasoningEffort = false; + result.defaultThinkingEnabled = false; + } + return result; +} + +/** Resolve fixed destination registry facts beneath exact operator capabilities. Unknown stays absent. */ +export function resolveReasoningCapabilities(provider: OcxProviderConfig, modelId: string): ReasoningCapabilities { + const entry = registryEntryForProviderDestination(provider); + return reasoningCapabilitiesFromConfig({ + ...provider, + modelCapabilities: mergeReasoningModelCapabilities(entry?.modelCapabilities, provider.modelCapabilities), + modelReasoningEfforts: mapFill(entry?.modelReasoningEfforts, provider.modelReasoningEfforts)[0], + reasoningEfforts: provider.reasoningEfforts ?? entry?.reasoningEfforts, + reasoningEffortMap: mapFill(entry?.reasoningEffortMap, provider.reasoningEffortMap)[0], + modelReasoningEffortMap: nestedMapFill(entry?.modelReasoningEffortMap, provider.modelReasoningEffortMap)[0], + thinkingBudgetModels: [...(entry?.thinkingBudgetModels ?? []), ...(provider.thinkingBudgetModels ?? [])], + noReasoningModels: [...(entry?.noReasoningModels ?? []), ...(provider.noReasoningModels ?? [])], + }, modelId); +} diff --git a/src/providers/registry/entries-extended.ts b/src/providers/registry/entries-extended.ts index 211b94fe5e7..e88f914ad36 100644 --- a/src/providers/registry/entries-extended.ts +++ b/src/providers/registry/entries-extended.ts @@ -9,6 +9,7 @@ import { MOONSHOT_INTL_BASE_URL, } from "../base-url-choices"; import { COMMAND_CODE_MODEL_REASONING_EFFORTS } from "../command-code-efforts"; +import { MIMO_THINKING_CAPABILITIES, MIMO_THINKING_MODELS } from "../mimo-thinking"; import { CODEBUDDY_CN_MODELS, CODEBUDDY_CN_MODEL_CONTEXT_WINDOWS, @@ -1291,11 +1292,11 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ // (https://mimo.mi.com/docs/en-US/updates/deprecate), so the first-party presets default to V2.6. // Saved defaults are not rewritten; V2.5 stays listed until it stops answering. // Both first-party presets read the xiaomi metadata bundle for window, output, modalities and price. - { id: "xiaomi", label: "Xiaomi MiMo", baseUrl: "https://api.xiaomimimo.com/anthropic", adapter: "anthropic", authKind: "key", dashboardUrl: "https://xiaomimimo.com", defaultModel: "mimo-v2.6-pro", models: ["mimo-v2.6-pro", "mimo-v2.6-flash", "mimo-v2.6-pro-ultraspeed", "mimo-v2.5-pro", "mimo-v2.5"], jawcodeBundle: "xiaomi" }, + { id: "xiaomi", label: "Xiaomi MiMo", baseUrl: "https://api.xiaomimimo.com/anthropic", adapter: "anthropic", authKind: "key", dashboardUrl: "https://xiaomimimo.com", defaultModel: "mimo-v2.6-pro", models: ["mimo-v2.6-pro", "mimo-v2.6-flash", "mimo-v2.6-pro-ultraspeed", "mimo-v2.5-pro", "mimo-v2.5"], jawcodeBundle: "xiaomi", modelCapabilities: MIMO_THINKING_CAPABILITIES, thinkingToggleModels: [...MIMO_THINKING_MODELS] }, // Xiaomi's public OpenAI-compatible endpoint is a distinct transport from both the Anthropic // preset above and the paid token-plan host below. Keep a separate fixed-destination contract - // so existing custom providers are never retargeted while the official route receives the - // strict reasoning ladder its validator enforces (#1483). + // so existing custom providers are never retargeted. V2.6 uses the documented thinking + // toggle; older models retain the reasoning ladder validated in #1483. { id: "xiaomi-mimo", label: "Xiaomi MiMo (OpenAI Chat)", @@ -1305,11 +1306,13 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ dashboardUrl: "https://platform.xiaomimimo.com/console/balance", defaultModel: "mimo-v2.6-flash", models: ["mimo-v2.6-flash", "mimo-v2.6-pro", "mimo-v2.6-pro-ultraspeed", "mimo-v2.5"], + modelCapabilities: MIMO_THINKING_CAPABILITIES, + thinkingToggleModels: [...MIMO_THINKING_MODELS], jawcodeBundle: "xiaomi", reasoningEfforts: ["low", "medium", "high"], reasoningEffortMap: { xhigh: "high", max: "high", ultra: "high" }, preserveCustomDestination: true, - note: "Official Xiaomi MiMo OpenAI-compatible Chat endpoint. The upstream validator accepts reasoning_effort none/low/medium/high; higher Codex tiers are clamped to high.", + note: "Official Xiaomi MiMo OpenAI-compatible Chat endpoint. V2.6 thinking is on/off: none disables it; high is the client-compatible on value, not a distinct reasoning strength. Older models retain their existing effort contract.", }, { id: "kilo", label: "Kilo", baseUrl: "https://api.kilo.ai/api/gateway", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://kilo.ai" }, { diff --git a/src/providers/registry/model-ids.ts b/src/providers/registry/model-ids.ts index 9c0a03a84c2..f00edad7f35 100644 --- a/src/providers/registry/model-ids.ts +++ b/src/providers/registry/model-ids.ts @@ -99,6 +99,7 @@ export const REGISTRY_FIELD_MODEL_ID_ROLES = { modelContextWindows: RECORD_KEYS, modelDisplayNames: RECORD_KEYS, modelInputModalities: RECORD_KEYS, + modelCapabilities: RECORD_KEYS, defaultMaxOutputTokens: NONE, modelMaxOutputTokens: RECORD_KEYS, reasoningEfforts: NONE, diff --git a/src/providers/registry/types.ts b/src/providers/registry/types.ts index 8b4b3d960cb..c5ce5bc51a1 100644 --- a/src/providers/registry/types.ts +++ b/src/providers/registry/types.ts @@ -318,6 +318,7 @@ export interface ProviderRegistryEntry { */ modelDisplayNames?: Record; modelInputModalities?: Record; + modelCapabilities?: OcxProviderConfig["modelCapabilities"]; defaultMaxOutputTokens?: number; modelMaxOutputTokens?: Record; reasoningEfforts?: string[]; @@ -396,7 +397,7 @@ export type ProviderConfigSeed = Pick< "anthropicOAuthInstance" | "adapter" | "baseUrl" | "apiKeyTransport" | "responsesPath" | "chatCompletionsPath" | "authMode" | "keyOptional" | "freeTier" | "modelSuffixBracketStrip" | "defaultModel" | "models" | "liveModels" | "contextWindow" | "modelContextWindows" | "modelInputModalities" - | "modelDisplayNames" + | "modelDisplayNames" | "modelCapabilities" | "modelMaxInputTokens" | "defaultMaxOutputTokens" | "modelMaxOutputTokens" | "reasoningEfforts" | "modelReasoningEfforts" | "modelDefaultReasoningEfforts" | "reasoningEffortMap" | "modelReasoningEffortMap" | "reasoningWireFormat" | "noVisionModels" | "noReasoningModels" | "noTemperatureModels" | "noTopPModels" | "noStopModels" | "noPenaltyModels" diff --git a/src/providers/resolved-model-policy.ts b/src/providers/resolved-model-policy.ts index 40bd7c858c3..08c1f4a1099 100644 --- a/src/providers/resolved-model-policy.ts +++ b/src/providers/resolved-model-policy.ts @@ -1,5 +1,6 @@ -import type { ModelCapabilities, OcxProviderConfig } from "../types"; -import { MODEL_ADAPTER_OVERRIDE_ALLOWED, pinnedWireAdapter } from "../types"; +import type { ModelCapabilities, OcxProviderConfig, ReasoningCapabilities } from "../types"; +import { declaredModelCapabilities, mergeReasoningModelCapabilities, reasoningCapabilitiesFromConfig, REASONING_CAPABILITY_FIELDS } from "./reasoning-capabilities"; +import { MODEL_ADAPTER_OVERRIDE_ALLOWED, modelInList, pinnedWireAdapter } from "../types"; import { isCanonicalOpenAiForwardProvider } from "./openai-tiers"; import { resolveProviderAuthTransport } from "./fastwire"; import { fastSwitchOff } from "./fast-opt-in"; @@ -48,7 +49,7 @@ export type ResolvedProviderStaticPolicy = Readonly; -export interface ResolvedPerModelStaticPolicy { +export interface ResolvedPerModelStaticPolicy extends Readonly { readonly adapter: string; readonly contextWindow?: number; readonly inputModalities?: readonly string[]; @@ -215,9 +216,11 @@ export function resolveModelPolicy(input: ResolveModelPolicyInput): ResolvedMode putMergedMap("modelSupportsReasoningSummaries", entry?.modelSupportsReasoningSummaries, provider.modelSupportsReasoningSummaries); putMergedMap("modelSupportsVerbosity", entry?.modelSupportsVerbosity, provider.modelSupportsVerbosity); for (const key of [ - "modelCapabilities", "modelAutoCompactTokenLimits", "modelSuppressSyntheticMax", + "modelAutoCompactTokenLimits", "modelSuppressSyntheticMax", "modelReasoningSummaryDelivery", "codexToolMode", "modelAdapters", ] as const) putScalar(key, undefined); + put("modelCapabilities", mergeReasoningModelCapabilities(entry?.modelCapabilities, provider.modelCapabilities), + provider.modelCapabilities !== undefined ? "operator-capability" : entry?.modelCapabilities !== undefined ? "registry" : "unknown"); for (const key of ["modelContextWindows", "modelMaxInputTokens"] as const) { const [value, source] = input.providerName === "openai-apikey" ? positiveCapMap(entry?.[key], provider[key]) @@ -325,7 +328,16 @@ export function resolveModelPolicy(input: ResolveModelPolicyInput): ResolvedMode : exactMaxInputTokens; const exactMaxOutputTokens = modelValue(providerPolicy.modelMaxOutputTokens); const modelMaxOutputTokens = exactMaxOutputTokens ?? providerPolicy.defaultMaxOutputTokens; - const modelReasoningEfforts = modelValue(providerPolicy.modelReasoningEfforts) ?? providerPolicy.reasoningEfforts; + const reasoningCapabilities = reasoningCapabilitiesFromConfig({ + ...providerPolicy, + adapter: providerPolicy.adapter ?? provider.adapter, + baseUrl: providerPolicy.baseUrl ?? provider.baseUrl, + ...(input.modelCapabilities ? { modelCapabilities: mergeReasoningModelCapabilities( + providerPolicy.modelCapabilities, { [input.modelId]: input.modelCapabilities }, + ) } : {}), + }, input.modelId); + const modelReasoningEfforts = reasoningCapabilities.supportsReasoningEffort === false ? [] + : modelValue(providerPolicy.modelReasoningEfforts) ?? providerPolicy.reasoningEfforts; const modelSupportsReasoningSummaries = modelValue(providerPolicy.modelSupportsReasoningSummaries) ?? (modelValue(providerPolicy.modelReasoningSummaryDelivery) !== undefined ? true : undefined); const modelSupportsVerbosity = modelValue(providerPolicy.modelSupportsVerbosity) ?? providerPolicy.supportsVerbosity; @@ -334,6 +346,7 @@ export function resolveModelPolicy(input: ResolveModelPolicyInput): ResolvedMode : modelValue(providerPolicy.modelSupportsServiceTier) ?? providerPolicy.supportsServiceTier; const model: ResolvedPerModelStaticPolicy = { adapter, + ...reasoningCapabilities, ...(modelContextWindow !== undefined ? { contextWindow: modelContextWindow } : {}), ...(capabilityModalities ?? modelValue(providerPolicy.modelInputModalities) !== undefined ? { inputModalities: capabilityModalities ?? modelValue(providerPolicy.modelInputModalities) } @@ -341,7 +354,7 @@ export function resolveModelPolicy(input: ResolveModelPolicyInput): ResolvedMode ...(modelMaxInputTokens !== undefined ? { maxInputTokens: modelMaxInputTokens } : {}), ...(modelMaxOutputTokens !== undefined ? { maxOutputTokens: modelMaxOutputTokens } : {}), ...(modelReasoningEfforts !== undefined ? { reasoningEfforts: modelReasoningEfforts } : {}), - ...(modelValue(providerPolicy.modelDefaultReasoningEfforts) !== undefined ? { defaultReasoningEffort: modelValue(providerPolicy.modelDefaultReasoningEfforts) } : {}), + ...(reasoningCapabilities.supportsReasoningEffort !== false && modelValue(providerPolicy.modelDefaultReasoningEfforts) !== undefined ? { defaultReasoningEffort: modelValue(providerPolicy.modelDefaultReasoningEfforts) } : {}), ...(modelSupportsReasoningSummaries !== undefined ? { supportsReasoningSummaries: modelSupportsReasoningSummaries } : {}), ...(modelSupportsVerbosity !== undefined ? { supportsVerbosity: modelSupportsVerbosity } : {}), ...(modelSupportsServiceTier !== undefined ? { supportsServiceTier: modelSupportsServiceTier } : {}), @@ -359,6 +372,13 @@ export function resolveModelPolicy(input: ResolveModelPolicyInput): ResolvedMode const modelProvenance: Partial> = { adapter: adapterSource, }; + for (const field of REASONING_CAPABILITY_FIELDS) { + modelProvenance[field] = reasoningCapabilities[field] === undefined ? "unknown" + : modelInList(provider.noReasoningModels, input.modelId) ? "operator" + : modelInList(entry?.noReasoningModels, input.modelId) ? "registry" + : input.modelCapabilities?.[field] !== undefined || declaredModelCapabilities(provider.modelCapabilities, input.modelId)?.[field] !== undefined + ? "operator-capability" : declaredModelCapabilities(entry?.modelCapabilities, input.modelId)?.[field] !== undefined ? "registry" : "operator"; + } modelProvenance.contextWindow = modelOrProviderSource(provider.modelContextWindows, entry?.modelContextWindows, provider.contextWindow, entry?.contextWindow); if (modelProvenance.contextWindow === "unknown" && familyContextWindow !== undefined) { modelProvenance.contextWindow = anthropicFamilyContextWindow(provider.modelContextWindows, input.modelId) !== undefined diff --git a/src/responses/parser.ts b/src/responses/parser.ts index 10bd953eb5e..575c2521cc9 100644 --- a/src/responses/parser.ts +++ b/src/responses/parser.ts @@ -185,8 +185,8 @@ function attachPendingReasoningToCallOwner( const REASONING_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh", "max"]); -export function hasValidatedActiveReasoningEffort(options: Pick): boolean { - return options.reasoning !== undefined && options.reasoning !== "none"; +export function hasValidatedActiveReasoningEffort(options: Pick): boolean { + return options.thinkingEnabled ?? (options.reasoning !== undefined && options.reasoning !== "none"); } @@ -695,6 +695,7 @@ export function parseRequest( // `reasoning_effort_for_request`), so current clients never send it — but a catalog that // advertises ultra plus an older/direct caller can. Degrade it to max like upstream instead of // silently dropping reasoning altogether. + if (data.reasoning?.enabled !== undefined) options.thinkingEnabled = data.reasoning.enabled; const requestedEffort = data.reasoning?.effort === "ultra" ? "max" : data.reasoning?.effort; if (requestedEffort && REASONING_EFFORTS.has(requestedEffort)) { options.reasoning = requestedEffort; diff --git a/src/responses/schema.ts b/src/responses/schema.ts index 864b4951139..d3fac2f8632 100644 --- a/src/responses/schema.ts +++ b/src/responses/schema.ts @@ -180,6 +180,7 @@ export const toolChoiceSchema = z.union([ ]); export const reasoningConfigSchema = z.object({ + enabled: z.boolean().optional(), effort: z.string().optional(), summary: z.enum(["auto", "concise", "detailed", "none"]).optional(), }); diff --git a/src/server/effort-policy.ts b/src/server/effort-policy.ts index 16db1cf3471..664fa7a0e4e 100644 --- a/src/server/effort-policy.ts +++ b/src/server/effort-policy.ts @@ -232,6 +232,7 @@ interface EffortSnapshot { modelId: string; reasoningPresent: boolean; reasoning: OcxParsedRequest["options"]["reasoning"]; + thinkingEnabled: boolean | undefined; rawEffortPresent: boolean; rawEffort: unknown; } @@ -239,7 +240,7 @@ interface EffortSnapshot { const effortSnapshots = new WeakMap(); /** Capture effective synthetic/combo defaults before final model namespace rewriting. - * A different destination restores effort alone; intervening summary/options edits survive. + * A different destination restores effort and thinking choice; intervening summary edits survive. * Credential retries do not change the destination and retain their existing decision. */ export function prepareEffortNormalization( @@ -255,6 +256,7 @@ export function prepareEffortNormalization( modelId: route.modelId, reasoningPresent: Object.hasOwn(parsed.options, "reasoning"), reasoning: parsed.options.reasoning, + thinkingEnabled: parsed.options.thinkingEnabled, rawEffortPresent: !!raw?.reasoning && Object.hasOwn(raw.reasoning, "effort"), rawEffort: raw?.reasoning?.effort, }); @@ -265,6 +267,8 @@ export function prepareEffortNormalization( } if (previous.reasoningPresent) parsed.options.reasoning = previous.reasoning; else delete parsed.options.reasoning; + if (previous.thinkingEnabled !== undefined) parsed.options.thinkingEnabled = previous.thinkingEnabled; + else delete parsed.options.thinkingEnabled; if (raw && previous.rawEffortPresent) { if (!raw.reasoning || typeof raw.reasoning !== "object") raw.reasoning = {}; raw.reasoning.effort = previous.rawEffort; diff --git a/src/server/index/serve-options.ts b/src/server/index/serve-options.ts index c680d02e2cb..781e73c7441 100644 --- a/src/server/index/serve-options.ts +++ b/src/server/index/serve-options.ts @@ -1304,9 +1304,10 @@ export function createServeOptions(ctx: ServeOptionsContext) { ...(effectiveId !== publicId ? { native_id: publicId } : {}), ...grokEffortFields(m.reasoningEfforts ?? [], m.defaultReasoningEffort), ...modelCapabilityFields({ + supportsReasoning: m.supportsReasoning, supportsReasoningEffort: m.supportsReasoningEffort, + supportsThinkingToggle: m.supportsThinkingToggle, defaultThinkingEnabled: m.defaultThinkingEnabled, reasoningEfforts: m.reasoningEfforts, - // contextWindow is already the post-cap effective value; contextCap is the raw - // operator knob and over-reports models whose real window sits below it. + // Use the post-cap window; the raw contextCap can exceed a model's real window. contextWindow: m.contextWindow, maxOutputTokens: m.maxOutputTokens, inputModalities: m.inputModalities, diff --git a/src/server/management/model-row-export-metadata.ts b/src/server/management/model-row-export-metadata.ts index f120a173c3d..72c1ebeb335 100644 --- a/src/server/management/model-row-export-metadata.ts +++ b/src/server/management/model-row-export-metadata.ts @@ -16,6 +16,7 @@ * and config already in hand. */ import { enrichProviderFromRegistry } from "../../providers/derive"; +import { reasoningCapabilitiesFromConfig } from "../../providers/reasoning-capabilities"; import { modelRecordValue } from "../../reasoning-effort"; import { configuredReasoningSummarySupport } from "../../codex/catalog/model-hints"; import type { CatalogModel } from "../../codex/catalog"; @@ -87,24 +88,42 @@ export function customRowExportMetadata( // same reader the materializer uses answers from the provider config, so both paths agree. const supportsReasoningSummaries = catalogRow?.supportsReasoningSummaries ?? configuredReasoningSummarySupport(provider, source.id); + // The effective ladder below already includes custom overrides; do not derive new axes + // from a provider-wide legacy ladder that a custom row may explicitly have cleared. + const configured = enriched === undefined ? undefined : reasoningCapabilitiesFromConfig({ + ...enriched, modelReasoningEfforts: undefined, reasoningEfforts: undefined, + }, source.id); + const supportsThinkingToggle = configured?.supportsThinkingToggle === false ? false + : catalogRow?.supportsThinkingToggle ?? configured?.supportsThinkingToggle; + const supportsReasoningEffort = configured?.supportsReasoningEffort === false ? false + : catalogRow?.supportsReasoningEffort ?? configured?.supportsReasoningEffort; + const defaultThinkingEnabled = configured?.defaultThinkingEnabled === false ? false + : catalogRow?.defaultThinkingEnabled ?? configured?.defaultThinkingEnabled; + const supportsReasoning = configured?.supportsReasoning === false ? false : catalogRow?.supportsReasoning + ?? configured?.supportsReasoning + ?? knownReasoningSupport({ reasoningEfforts: effectiveReasoningEfforts, supportsReasoningSummaries, + supportsThinkingToggle, supportsReasoningEffort }); + const reasoningEfforts = supportsReasoningEffort === false || supportsReasoning === false + ? (effectiveReasoningEfforts === undefined ? undefined : []) : effectiveReasoningEfforts; const defaultReasoningEffort = declaredDefaultWithin( - effectiveReasoningEfforts, - defaultEffort(source, catalogRow, provider), + reasoningEfforts, + supportsReasoningEffort === false || supportsReasoning === false ? undefined : defaultEffort(source, catalogRow, provider), ); return { ...(contextWindow !== undefined ? { contextWindow } : {}), ...(catalogRow?.maxInputTokens !== undefined ? { maxInputTokens: catalogRow.maxInputTokens } : {}), ...(catalogRow?.maxOutputTokens !== undefined ? { maxTokens: catalogRow.maxOutputTokens } : {}), ...(inputModalities !== undefined ? { inputModalities: [...inputModalities] } : {}), - ...(effectiveReasoningEfforts !== undefined ? { reasoningEfforts: [...effectiveReasoningEfforts] } : {}), + ...(reasoningEfforts !== undefined ? { reasoningEfforts: [...reasoningEfforts] } : {}), + ...(supportsThinkingToggle !== undefined ? { supportsThinkingToggle } : {}), + ...(supportsReasoningEffort !== undefined ? { supportsReasoningEffort } : {}), + ...(defaultThinkingEnabled !== undefined ? { defaultThinkingEnabled } : {}), ...(defaultReasoningEffort !== undefined ? { defaultReasoningEffort } : {}), ...(knownToolsSupport({ capabilities: catalogRow?.capabilities, parallelToolCalls: catalogRow?.parallelToolCalls, }) === true ? { supportsTools: true } : {}), - ...(knownReasoningSupport({ reasoningEfforts: effectiveReasoningEfforts, supportsReasoningSummaries }) === true - ? { supportsReasoning: true } - : {}), + ...(typeof supportsReasoning === "boolean" ? { supportsReasoning } : {}), ...(supportsReasoningSummaries !== undefined ? { supportsReasoningSummaries } : {}), }; } diff --git a/src/server/management/model-rows.ts b/src/server/management/model-rows.ts index 479c0c880cc..c4d5154825e 100644 --- a/src/server/management/model-rows.ts +++ b/src/server/management/model-rows.ts @@ -39,6 +39,7 @@ import { type ExportModel, } from "../../clients/config-export/contracts"; import { customRowExportMetadata } from "./model-row-export-metadata"; +import { resolveExportReasoningAxes } from "../../clients/config-export/reasoning-metadata"; import { providerContextCap } from "../../providers/context-cap"; import { isVisionReasoningEffort, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort"; import { routedSlug, slugEquals } from "../../providers/slug-codec"; @@ -104,9 +105,9 @@ export type ManagementModelRow = Partial & { */ supportsTools?: boolean; /** - * True when a canonical source positively asserts reasoning (a non-empty effective ladder - * or delivered reasoning summaries). Absent means unknown, never "cannot reason": an empty - * ladder only says the effort is not adjustable. + * A canonical source's explicit capability, or positive evidence from reasoning efforts + * or delivered summaries. Absent means unknown; an empty ladder alone says only that + * effort is not adjustable, not that the model cannot reason. */ supportsReasoning?: boolean; /** @@ -460,9 +461,11 @@ export async function listManagementModelRows( const routed = m.provider !== "combo"; // Resolved once each: the spread below used to evaluate the same helpers again for the guard. const reasoningEfforts = routed - ? effectiveModelReasoningEfforts(config, m.provider, m.id, m.reasoningEfforts, catalogLadders) + ? m.supportsReasoningEffort === false || m.supportsReasoning === false ? [] + : effectiveModelReasoningEfforts(config, m.provider, m.id, m.reasoningEfforts, catalogLadders) : undefined; const defaultReasoningEffort = routed + && m.supportsReasoningEffort !== false && m.supportsReasoning !== false ? effectiveModelDefaultReasoningEffort(config, m.provider, m.id, m.defaultReasoningEffort, reasoningEfforts) : undefined; const inputModalitiesDeclared = routed ? declaredModelInputModalities(config, m.provider, m.id) : undefined; @@ -524,10 +527,7 @@ export async function listManagementModelRows( }); const supportsReasoning = row.exportMetadata?.supportsReasoning ?? row.supportsReasoning - ?? knownReasoningSupport({ - reasoningEfforts: row.reasoningEfforts, - supportsReasoningSummaries: row.supportsReasoningSummaries, - }); + ?? knownReasoningSupport(row); return { ...row, // One shared definition with the request path: the GUI must not re-derive consumer @@ -560,14 +560,8 @@ export function toExportModel(row: ManagementModelRow): ExportModel { const maxTokens = effective?.maxTokens ?? row.maxOutputTokens; const maxInputTokens = effective?.maxInputTokens ?? row.maxInputTokens; const inputModalities = effective?.inputModalities ?? row.inputModalities; - const reasoningEfforts = effective?.reasoningEfforts ?? row.reasoningEfforts; - // The projection is the authority on the default. When it exists, an absent default means - // NO declared default — falling back to the row would reintroduce the picker's synthetic - // medium/low value, which is exactly what this projection exists to keep out of exports. - // Rows without a projection (native-era shapes, hand-built rows) keep the row's own value. - const defaultReasoningEffort = effective !== undefined - ? effective.defaultReasoningEffort - : row.defaultReasoningEffort; + const { supportsThinkingToggle, supportsReasoningEffort, defaultThinkingEnabled, + reasoningEfforts, defaultReasoningEffort } = resolveExportReasoningAxes(effective, row); const supportsReasoningSummaries = effective?.supportsReasoningSummaries ?? row.supportsReasoningSummaries; // Projection first, then the row's own explicit boolean (an external producer may state // false, which is evidence), and only then derivation from positive evidence. @@ -580,10 +574,8 @@ export function toExportModel(row: ManagementModelRow): ExportModel { }); const supportsReasoning = effective?.supportsReasoning ?? row.supportsReasoning - ?? knownReasoningSupport({ - reasoningEfforts: row.reasoningEfforts, - supportsReasoningSummaries: row.supportsReasoningSummaries, - }); + ?? knownReasoningSupport({ ...row, supportsThinkingToggle, supportsReasoningEffort, + reasoningEfforts, supportsReasoningSummaries }); return { namespaced: row.namespaced, provider: row.provider, @@ -597,6 +589,9 @@ export function toExportModel(row: ManagementModelRow): ExportModel { ...(inputModalities ? { inputModalities } : {}), // An empty array is a declaration ("no rungs"), and `[]` is truthy: it survives here. ...(reasoningEfforts ? { reasoningEfforts } : {}), + ...(typeof supportsThinkingToggle === "boolean" ? { supportsThinkingToggle } : {}), + ...(typeof supportsReasoningEffort === "boolean" ? { supportsReasoningEffort } : {}), + ...(typeof defaultThinkingEnabled === "boolean" ? { defaultThinkingEnabled } : {}), ...(defaultReasoningEffort ? { defaultReasoningEffort } : {}), // An explicit false is evidence and survives; only derivation is true-or-nothing. ...(typeof supportsTools === "boolean" ? { supportsTools } : {}), diff --git a/src/server/models-capabilities.ts b/src/server/models-capabilities.ts index 061d3725b82..2eedefff06c 100644 --- a/src/server/models-capabilities.ts +++ b/src/server/models-capabilities.ts @@ -110,6 +110,10 @@ export function predictCursorEffort( } export interface ModelCapabilityInput { + supportsReasoning?: boolean; + supportsThinkingToggle?: boolean; + supportsReasoningEffort?: boolean; + defaultThinkingEnabled?: boolean; reasoningEfforts?: readonly string[]; contextWindow?: number; /** @@ -133,7 +137,10 @@ export interface ModelCapabilityFields { input_modalities?: string[]; supports_tool_use: true; supports_streaming: true; - supports_reasoning: boolean; + supports_reasoning?: boolean; + supports_thinking_toggle?: boolean; + supports_reasoning_effort?: boolean; + default_thinking_enabled?: boolean; supports_vision?: boolean; reasoning_effort?: string[]; }; @@ -166,7 +173,15 @@ function positiveInt(value: unknown): number | undefined { } export function modelCapabilityFields(input: ModelCapabilityInput): ModelCapabilityFields { - const efforts = (input.reasoningEfforts ?? []).filter(effort => typeof effort === "string" && effort.length > 0); + const efforts = input.supportsReasoning === false || input.supportsReasoningEffort === false ? [] + : (input.reasoningEfforts ?? []).filter(effort => typeof effort === "string" && effort.length > 0); + // Legacy discovery used an explicit empty ladder to withhold Cursor reasoning controls. + // Independent capability declarations replace that compatibility inference per axis. + const legacyEmptyLadder = input.reasoningEfforts?.length === 0 + && input.supportsThinkingToggle === undefined && input.supportsReasoningEffort === undefined; + const supportsReasoning = input.supportsReasoning + ?? (input.supportsThinkingToggle === true || input.supportsReasoningEffort === true || efforts.length > 0 + ? true : legacyEmptyLadder ? false : undefined); const contextLength = positiveInt(input.contextWindow); const longContextLength = positiveInt(input.longContextWindow); const maxOutputTokens = positiveInt(input.maxOutputTokens); @@ -187,7 +202,10 @@ export function modelCapabilityFields(input: ModelCapabilityInput): ModelCapabil ...(modalities !== undefined && modalities.length > 0 ? { input_modalities: [...modalities] } : {}), supports_tool_use: true, supports_streaming: true, - supports_reasoning: efforts.length > 0, + ...(supportsReasoning !== undefined ? { supports_reasoning: supportsReasoning } : {}), + ...(input.supportsThinkingToggle !== undefined ? { supports_thinking_toggle: input.supportsThinkingToggle } : {}), + ...(input.supportsReasoningEffort !== undefined ? { supports_reasoning_effort: input.supportsReasoningEffort } : {}), + ...(input.defaultThinkingEnabled !== undefined ? { default_thinking_enabled: input.defaultThinkingEnabled } : {}), ...(supportsVision !== undefined ? { supports_vision: supportsVision } : {}), ...(efforts.length > 0 ? { reasoning_effort: [...efforts] } : {}), }, diff --git a/src/server/responses/core-normalize.ts b/src/server/responses/core-normalize.ts index 9d76d58c710..880f3a4d099 100644 --- a/src/server/responses/core-normalize.ts +++ b/src/server/responses/core-normalize.ts @@ -1,4 +1,6 @@ import { effectiveAzureOutputBudget } from "../../adapters/azure"; +import { normalizeThinkingOptions } from "../../clients/reasoning-controls"; +import { nativeEffortClamp, shouldApplyNativeEffortClamp } from "../../codex/catalog"; import { sanitizeLogMetadataString } from "../../lib/redact"; import type { RouteResult } from "../../router"; import type { InboundWire } from "../../providers/registry"; @@ -341,6 +343,15 @@ export async function applyFinalRouteRequestNormalization(args: { } { + // Native passthrough keeps its snapshot-owned effort contract, including saved exclusions. + if (!shouldApplyNativeEffortClamp(route.providerName, route.provider, finalSelectedModelId)) { + const previousEffort = parsed.options.reasoning; + normalizeThinkingOptions(route.provider, route.modelId, parsed.options); + if (previousEffort !== undefined && parsed.options.reasoning === undefined) { + const raw = parsed._rawBody as { reasoning?: unknown } | undefined; + if (raw) raw.reasoning = stripEmptyLadderEffort(raw.reasoning, []); + } + } const { applyEffortCap, effortCapAppliesTo, supportedLadderFor } = await import("../effort-policy"); const surface = collabSurface(parsed); if (effortCapAppliesTo(surface, req.headers, config, parsed._compactionRequest === true)) { @@ -357,7 +368,6 @@ export async function applyFinalRouteRequestNormalization(args: { } { - const { nativeEffortClamp, shouldApplyNativeEffortClamp } = await import("../../codex/catalog"); const raw = parsed._rawBody as { reasoning?: { effort?: string } } | undefined; // The parser folds `ultra` to `max` only in options.reasoning; the passthrough adapter // serializes the raw body, so a raw `ultra` must reach the clamp itself (#7590). diff --git a/src/types.ts b/src/types.ts index 5e97032e752..99e08560f2c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -134,6 +134,7 @@ export type { TierDecision, OcxProviderConfig, ModelCapabilities, + ReasoningCapabilities, } from "./types/provider"; export { PROVIDER_WEB_SEARCH_BRIDGE_BACKENDS } from "./types/provider"; diff --git a/src/types/provider.ts b/src/types/provider.ts index 45f4ac6da72..d14f989d5f4 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -278,12 +278,16 @@ export type TierDecision = | { readonly kind: "drop" } | { readonly kind: "set"; readonly value: string }; -/** - * One configured provider entry. `authMode` (default `"key"`) decides whether same-target 429 - * retries are allowed; OAuth/forward credentials and local runtimes are never replayed. - */ +/** Independent reasoning axes. Absence means unknown, not an explicit denial. */ +export interface ReasoningCapabilities { + supportsReasoning?: boolean; + supportsThinkingToggle?: boolean; + supportsReasoningEffort?: boolean; + defaultThinkingEnabled?: boolean; +} + /** Explicit per-model operator declarations; absent axes keep legacy behavior. */ -export interface ModelCapabilities { +export interface ModelCapabilities extends ReasoningCapabilities { inputModalities?: Array<"text" | "image" | "audio" | "video">; /** Requested tier only; does not imply an upstream window or activate an unverified wire. */ contextTier?: "default" | "long_context"; @@ -292,6 +296,10 @@ export interface ModelCapabilities { deepseekImageFiles?: boolean; } +/** + * One configured provider entry. `authMode` (default `"key"`) decides whether same-target 429 + * retries are allowed; OAuth/forward credentials and local runtimes are never replayed. + */ export interface OcxProviderConfig { /** Explicit builtin Pool 2 provenance; endpoints never imply ownership. */ anthropicOAuthInstance?: "anthropic2"; @@ -900,7 +908,7 @@ export interface OcxProviderConfig { * `reasoningEffortMap`, resolved per model first. */ modelReasoningEffortMap?: Record>; - /** OpenAI-compatible gateway reasoning wire shape. Default sends `reasoning_effort`. */ + /** Chat gateway reasoning object; also opts custom Responses wires into `reasoning.enabled`. */ reasoningWireFormat?: "gateway-object"; /** * Model ids that do NOT support a reasoning/thinking parameter. The openai-chat adapter drops diff --git a/src/types/request.ts b/src/types/request.ts index b184d4ca450..4d5170b45cf 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -392,6 +392,11 @@ export interface OcxRequestOptions { toolChoice?: OcxToolChoice; parallelToolCalls?: boolean; reasoning?: string; + /** + * Independent reasoning toggle resolved at client-compatibility ingress. This is distinct from + * the strength-like `reasoning` effort: toggle-only models consume this value as `thinking.type`. + */ + thinkingEnabled?: boolean; hideThinkingSummary?: boolean; /** Provider policy: suppress raw content-channel reasoning while summaries stay visible. */ hideRawReasoning?: boolean; diff --git a/structure/catalog.md b/structure/catalog.md index 0b7be713aa7..b48f4cb7b56 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -22,6 +22,7 @@ Accounts added through the [Orca import](codex-home.md#orca-source-owned-account validation-pending. Import alone supplies no entitlement evidence for the model catalog. ## Shared catalog +`src/server/models-capabilities.ts` preserves the legacy `/v1/models` negative reasoning signal for an explicit empty effort ladder when no reasoning, thinking-toggle or effort capability is declared. Explicit capability axes take precedence; a declared thinking toggle or reasoning support remains available with no effort rungs. This compatibility projection does not change Codex ladders, Claude thinking discovery, client exports or request wire policy. Vision-sidecar attachment metadata is projected after provider discovery/cache reads in `src/codex/catalog/routed-gather.ts`. The gather captures `visionSidecar.enabled !== false` before awaiting discovery, and `src/codex/catalog/gather-capture.ts` includes that effective flag in the flight key. Provider TTL/stale rows exclude synthetic sidecar image input; custom rows and synthesized combo members use the same captured switch. Explicit/native image modalities remain intact, and enabled advertisement retains the existing config-based consumer predicate without probing credential availability. An effective Vision switch change through `src/server/management/config-routes.ts` invokes the catalog-only management funnel after persistence to publish both catalog and cache; incomplete work is exposed as `catalogRefreshPending` without claiming client reload. Coverage: `tests/codex-integration/catalog-vision-sidecar-disabled.test.ts` and `tests/vision/sidecar-settings-vision-catalog.test.ts`. Custom-provider missing windows use [source-labelled vendor estimates](catalog-vendor-context.md) only when the provider opts in with `inferContextWindow: true`; these remain distinct from asserted routing capacity. Static policy and observed catalog evidence are separate authorities. Antigravity grouping and selection projection follow the [discovered effort-family contract](providers-and-adapters.md#antigravity-effort-families). @@ -479,11 +480,13 @@ The shared Responses path follows the [bounded multipart recovery contract](suba ## Ultra reasoning level -Ultra is always advertised in the catalog regardless of the `multi_agent_v2` toggle. The v2 toggle -controls only the multi-agent collab surface, not ultra visibility. The `nativeEffortClamp` function -wire-clamps ultra/max to each model's real top rung (e.g. gpt-5.5 ultra → xhigh on the wire). `ultra` itself is never a native wire value: the ChatGPT backend accepts `none` through `max`, so the clamp folds it to `max` even on rows whose pinned ladder lists it (`gpt-6-astra`, `gpt-6.1-sol`), and `src/server/responses/core-normalize.ts` applies that to the raw body the passthrough serializes, which the parser's options-only fold does not reach (#7590; `tests/responses/responses-native-ultra-effort-wire.test.ts`). For canonical OpenAI forwarding, other unsupported efforts map to the nearest published rung at or below the request, or the lowest rung when below the model minimum (`gpt-6.1-sol` `none` → `low`), after final model selection, including combo failover. +Catalog rows retain independent reasoning facts from [per-model declarations](config.md#explicit-per-model-capability-declarations) and discovered optional snake-case capability flags. `src/codex/catalog/model-hints.ts` reapplies config over original discovered reasoning metadata rather than a previous projection; removing an override restores that source at the catalog clone sites that explicitly propagate it. Absent axes remain unknown and explicit false survives publication. -For routed models, `modelSuppressSyntheticMax` is a catalog-only per-model setting. A true value +`src/codex/catalog/effort.ts` uses the [client export projection](clients/integrations.md#model-output-limits) at Codex's effort-enum boundary: toggle-only models receive `none/high` off/on choices and a declared thinking default, without recording those choices as model strengths. Explicit toggle-axis declarations or negative reasoning/effort declarations constrain this projection and suppress synthetic top tiers. Positive strength support inferred from a legacy ladder alone retains its previous synthesis. + +Legacy catalog ladders advertise ultra regardless of the `multi_agent_v2` toggle, which controls only the multi-agent collab surface. `nativeEffortClamp` wire-clamps ultra/max to each model's real top rung (e.g. gpt-5.5 ultra → xhigh on the wire). `ultra` itself is never a native wire value: the ChatGPT backend accepts `none` through `max`, so the clamp folds it to `max` even on rows whose pinned ladder lists it (`gpt-6-astra`, `gpt-6.1-sol`). `src/server/responses/core-normalize.ts` also applies the clamp to the raw passthrough body, which the parser's options-only fold does not reach (#7590; `tests/responses/responses-native-ultra-effort-wire.test.ts`). For canonical OpenAI forwarding, other unsupported efforts map to the nearest published rung at or below the request, or the lowest rung when below the model minimum (`gpt-6.1-sol` `none` → `low`), after final model selection, including combo failover. + +For routed models retaining legacy ladder synthesis, `modelSuppressSyntheticMax` is a catalog-only per-model setting. A true value prevents `src/codex/catalog/effort.ts` from adding a missing synthetic `max` and prevents `src/codex/catalog/build-entries.ts` from repairing that missing rung during observed-state merge. It never removes a provider-declared `max`, and `ultra` remains advertised. If the configured default @@ -494,14 +497,9 @@ can remove the former. Codex uses this same membership for the picker and explic effort validation; an explicit `max` spawn can therefore fail client-side before proxy wire clamping, while retained `ultra` remains the supported harness path. -`effortCap` and `subagentEffortCap` are hard ceilings applied on the V2 path -(`src/server/effort-policy.ts`): they lower or preserve the requested effort rather than rejecting -the request, and they never raise it. +`effortCap` and `subagentEffortCap` are hard ceilings applied on the V2 path (`src/server/effort-policy.ts`): they lower or preserve the requested effort rather than rejecting the request, and they never raise it. -Combo dispatch reads the final target ladder through the same `supportedLadderFor` authority. An -explicit empty ladder means that target receives no effort control; an unknown ladder receives no -parent effort controls only when the combo opts into `reasoningEffortMode: "adaptive"`. Known -non-empty ladders continue through the existing per-target resolution. +Combo dispatch reads the final target ladder through the same `supportedLadderFor` authority. An explicit empty ladder means that target receives no effort control; an unknown ladder receives no parent effort controls only when the combo opts into `reasoningEffortMode: "adaptive"`. Known non-empty ladders continue through the existing per-target resolution. Independent `reasoning.enabled` survives this strength-only stripping. The `ocx effort` CLI accepts only the same canonical cap ladder before live probing or persistence. Its status output preserves unsupported legacy cap values and reports that those fields are ignored; @@ -512,7 +510,7 @@ Operator-owned `pinnedReasoningEffort`, `modelPinnedReasoningEfforts`, and root `modelPinnedEfforts` resolve before applicable effort caps at the final destination. Provider model pins precede provider-wide pins, then global selector/destination pins. A pin can raise the effective caller effort; the later cap can still lower or omit it. -`none` means explicit-effort omission (provider default), not guaranteed reasoning disablement. +On legacy effort paths, `none` means explicit-effort omission (provider default), not guaranteed reasoning disablement. For a declared thinking toggle, the client compatibility decoder resolves `none` to thinking off before strength policy; an independent `thinkingEnabled` boolean takes precedence. Effort caps do not clear that switch, and a destination change restores the original thinking choice alongside effort before normalization. Compaction maintenance is exempt. Pins are user overlays and do not alter registry seeds, model discovery or advertised ladders. Native Chat applies qualifying caps even without a pin, and normalizes pinned values and values rewritten by a cap through provider wire @@ -577,7 +575,7 @@ Exact [model input declarations](config.md#explicit-per-model-capability-declara ## Renamed destination reasoning metadata -`src/providers/derive.ts` fills missing reasoning tables for renamed providers accepted by the existing fixed-key destination matcher. Model entries are cloned and explicit user entries (including empty arrays) win, matched case-insensitively. Provider-wide effort defaults fill only when undefined; Command Code unknown models therefore keep the registry's empty picker policy unless overridden. Identity, transport and other capability axes are unchanged. The gathered row drives client exports; this metadata contract does not prove arbitrary gateway routing. +`src/providers/derive.ts` fills missing reasoning tables for renamed providers accepted by the existing fixed-key destination matcher. Model entries are cloned and explicit user entries (including empty arrays) win, matched case-insensitively. Provider-wide effort defaults fill only when undefined; Command Code unknown models therefore keep the registry's empty picker policy unless overridden. Independent reasoning booleans merge per exact model and axis under the [capability declaration contract](config.md#explicit-per-model-capability-declarations). Identity, transport and unrelated capability axes are unchanged. The gathered row drives client exports; this metadata contract does not prove arbitrary gateway routing. Shared response-log retention and native SSE inspection pacing follow the [bounded inspection contract](transports/byte-accounting.md#response-log-inspection); other subsystem behavior remains unchanged. diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 99ddb05241d..620c266f250 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -541,7 +541,7 @@ The account history response can include a [low-confidence effective capacity es Account quota surfaces use [safe probe diagnostics](../transports/inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. -Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](../transports/responses.md) documents this boundary, and native Chat capability stripping applies only for an explicit empty declaration or no-reasoning model; an initial JEV null choice separately strips caller effort. +Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](../transports/responses.md) documents this boundary, and native Chat also honors explicit per-axis capability declarations; an initial JEV null choice separately strips caller effort. Live sideband admission and its bounded upstream handshake follow the [runtime contract](../runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. @@ -575,7 +575,7 @@ Translated tool results in `src/claude/inbound.ts` retain nonempty string `tool_ ## Claude Code picker descriptions -`src/claude/model-info.ts` gives every readable (`idStyle: "readable"`, Claude Code CLI) `/v1/models` row a `description` that Claude Code 2.1.257 and later shows under the picker entry instead of the generic "From gateway": `Routed by OpenCodex to native ` for native rows and `Routed by OpenCodex to /` for routed rows. The 1M copy keeps the base description and a Fast sibling appends ` · Fast`. Desktop 3P rows keep the ModelInfo shape without a description. `src/claude/gateway-cache.ts` preserves a string `description` when it refreshes and rewrites the gateway-model cache and drops any other type. `tests/claude-integration/claude-model-info.test.ts` and `tests/claude-integration/claude-gateway-cache.test.ts` cover both. +Routed discovery projects reasoning support independently of effort rungs: fixed reasoners retain thinking support without controls, toggle-only models expose enabled thinking without adaptive effort, and explicit false capability declarations constrain the projection. Unknown legacy axes retain their ladder-based defaults. `src/claude/model-info.ts` gives every readable (`idStyle: "readable"`, Claude Code CLI) `/v1/models` row a `description` that Claude Code 2.1.257 and later shows under the picker entry instead of the generic "From gateway": `Routed by OpenCodex to native ` for native rows and `Routed by OpenCodex to /` for routed rows. The 1M copy keeps the base description and a Fast sibling appends ` · Fast`. Desktop 3P rows keep the ModelInfo shape without a description. `src/claude/gateway-cache.ts` preserves a string `description` when it refreshes and rewrites the gateway-model cache and drops any other type. `tests/claude-integration/claude-model-info.test.ts` and `tests/claude-integration/claude-gateway-cache.test.ts` cover both. ## Claude Code routed aliases and the context window diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md index a4983696b29..f9e531969da 100644 --- a/structure/clients/integrations.md +++ b/structure/clients/integrations.md @@ -16,6 +16,7 @@ parsing and ownership rules below. | Module | Responsibility | | --- | --- | | `src/clients/config-export.ts` | Pure per-client config builders and the exact managed fragments each client receives. It never writes files. | +| `src/clients/reasoning-controls.ts` | Ingress compatibility for effort-enum clients: `resolveThinkingEnabled(provider, modelId, options)` decodes toggle aliases into `thinkingEnabled` before strength policy runs, preserving an explicit thinking choice and canonical provider defaults/maps. `normalizeThinkingOptions` removes the alias when real effort is unsupported. | | `src/integrations/registry.ts` | Canonical config/detection paths, current-provider-store declarations, source-preserving YAML declarations, writer-lock behavior, and client IDs. | | `src/integrations/target.ts` | Which file one operation reads, writes and records, and whether a write there reaches the client. | | `src/integrations/config-io.ts` | Bounded regular-file loading and parsing; production reads reject symlinks and stop after 4 MiB, and writes past the same bound are refused so a mutation cannot strand a file it cannot read back. `src/lib/bounded-file-read.ts` starts its buffer from the opened size and grows only as needed, retaining the limit-plus-one overflow probe when a file grows concurrently and returning only bytes actually read. Values that cannot round-trip through the target serializer are rejected before mutation. | @@ -242,8 +243,7 @@ than reviving a cleared capability. Exporters do not invent a medium default for `src/server/management/model-row-export-metadata.ts` resolves custom-row inheritance without backfilling the stored override fields the editor reads. Management rows attach `exportMetadata`; -its presence makes even an absent default authoritative, so export/launcher projections cannot -fall back to the picker's synthesized medium/low preference. Declared empty ladders survive. +its presence makes even an absent default authoritative; the shared `resolveExportReasoningAxes` helper in `src/clients/config-export/reasoning-metadata.ts` keeps management and CLI exports aligned, so export/launcher projections cannot fall back to the picker's synthesized medium/low preference. Declared empty ladders survive. Management and CLI export projections preserve independent `supportsReasoning`, `supportsThinkingToggle`, `supportsReasoningEffort` and `defaultThinkingEnabled` fields, including explicit false; absent axes remain unknown. An empty effort declaration does not disable a declared thinking toggle. `supportsReasoningEffort: false` prevents legacy ladders from advertising strengths. Metadata projections carry capability truth without synthesizing effort choices. At the Codex, OpenCode and Kilo effort-enum boundaries only, toggle-only models encode off/on as `none`/`high`, with their default taken from `defaultThinkingEnabled`; fixed reasoning creates no picker, while effort-only and combined controls retain real strengths without synthetic top tiers. Legacy unknown capabilities keep their prior behavior: positive effort support alone, which can be inferred from an existing ladder, does not opt a Codex row out of its previous max/ultra synthesis. Explicit toggle-axis declarations and negative reasoning/effort capabilities still constrain that projection exactly. Legacy variant maps disable client-generated choices that the model does not support; client-owned labels may still display “High”. `tests/clients/client-export-reasoning-controls.test.ts` covers generic capability combinations and Codex projection, `tests/clients/client-export-effective-metadata.test.ts` covers routed/custom projection parity, and `tests/server/api-catalog-route.test.ts` checks independent public API flags without a fake effort list and client exports with upstream fetches stubbed. OpenCode V1 and Kilo use model `options` defaults and variant maps; OpenCode V2 uses model `settings` defaults and native variant arrays with `@opencode/ai/providers/openai-compatible`. Declared variants, including `none`, override those defaults. V2 always writes an array, empty diff --git a/structure/config.md b/structure/config.md index 9ed99cee5cc..e8752faa4c5 100644 --- a/structure/config.md +++ b/structure/config.md @@ -422,9 +422,7 @@ destinations keep their operator-owned values. Key-auth service-tier defaults ap captured key authority; exact-model provenance comes from the merged key/registry map, then falls back to the resolved provider capability provenance. A model max-input value is bounded by its resolved context window. -Legacy model maps resolve exact id, then the base before a colon suffix, then case-folded exact id; -the separately captured explicit capability row remains exact-only. Per-model provenance is assigned -from the key that wins that same merged lookup, not from an independent source search. +Legacy model maps resolve exact id, then the base before a colon suffix, then case-folded exact id; the separately captured explicit capability row remains exact-only. Per-model provenance is assigned from the key that wins that same merged lookup, not from an independent source search. Provider seed/enrichment and request routing consume the same field-level resolver. Persisted config still stores operator intent rather than the frozen result; registry-only policy is applied at capture/route time and explicit false or empty declarations retain their field-specific meaning. @@ -561,7 +559,9 @@ The optional `autoReviewOverride` block stores a global approval reviewer select Display-name validation retains prototype-shaped model IDs as data; reviewer-target map validation remains separate and rejects its reserved keys. ## Explicit per-model capability declarations -`modelCapabilities` on `src/types/provider.ts` stores exact model-ID entries with optional inputModalities, contextTier, video.processing and deepseekImageFiles axes. `src/config/provider-validation.ts` strictly validates writes and merges PATCH axes without sharing live objects; null map/model/axis/processing tombstones delete, while empty PATCH objects do nothing. Complete POST/PUT replacements reject tombstones. File reads retain valid axes; malformed explicit modalities restrict to text with a diagnostic. The two catalog writers receive explicit config and gather fingerprints include the map. This storage contract alone does not activate a context tier, advertise a larger window or enable video processing. Separately, `modelContextTiers` actively selects `default` or `long_context` for GitHub Copilot models: config and management writes validate exact IDs, PATCH merges entries and null clears the map, CLI edit writes the map, and OAuth login preserves it. +`modelCapabilities` on `src/types/provider.ts` stores exact model-ID entries with optional inputModalities, contextTier, video.processing, deepseekImageFiles and reasoning axes. The reasoning axes are strict optional booleans: `supportsReasoning`, `supportsThinkingToggle`, `supportsReasoningEffort` and `defaultThinkingEnabled`. Each merges independently over the registry row matched by case-insensitive model ID; an unrelated operator axis does not erase registry reasoning facts. Omitted axes may inherit registry or supported legacy evidence; absent evidence stays unknown. Explicit false is retained, and `supportsReasoning: false` suppresses both controls and the thinking default. These facts are separate from the `thinkingToggleModels` wire declaration and from client-only effort aliases; see [reasoning capability resolution](providers-and-adapters.md). + +`src/config/provider-validation.ts` strictly validates writes and merges PATCH axes without sharing live objects; null map/model/axis/processing tombstones delete, while empty PATCH objects do nothing. Complete POST/PUT replacements reject tombstones. File reads retain valid sibling axes when a reasoning boolean is malformed; malformed explicit modalities restrict to text with a diagnostic. The two catalog writers receive explicit config and gather fingerprints include the map. This storage contract alone does not activate a context tier, advertise a larger window or enable video processing. Separately, `modelContextTiers` actively selects `default` or `long_context` for GitHub Copilot models: config and management writes validate exact IDs, PATCH merges entries and null clears the map, CLI edit writes the map, and OAuth login preserves it. The text-only consumer reads exact inputModalities declarations before legacy hints. CLI add/edit `--text-only` targets one model and preserves sibling declarations; `src/vision/eligibility.ts` routes declared text-only models into existing image-description or explicit-omission handling. Positive routed image declarations override stale candidate metadata, while native catalog authority retains its existing legacy policy. `PUT /api/combos` accepts a request-only `visionSidecarTargets` array of exact combo-member `{ provider, model }` pairs (validated against the submitted combo's normalized targets, deduplicated, rejected while `imageInput` is `"disabled"`). An entry whose provider is unconfigured, whose model already accepts image input through any source, or whose exact declaration, custom row, or provider `modelInputModalities` hint lacks `text` is rejected with 400 instead of being overwritten; an entry the sidecar already covers through any runtime source (exact declaration, custom row, `noVisionModels`, legacy record, registry enrichment) is skipped as a no-op. Accepted entries merge `inputModalities: ["text"]` through the same validation/merge helpers, and the combo write, reference migrations, disabled-selector rewrite and capability patches commit in one provider-patch transaction — a failed save restores all of them. The field never persists under `config.combos`, and removing a member from the combo leaves its provider declaration in place until cleared explicitly. diff --git a/structure/dashboard-and-usage.md b/structure/dashboard-and-usage.md index ed3c82bb6ff..ff7dbf76deb 100644 --- a/structure/dashboard-and-usage.md +++ b/structure/dashboard-and-usage.md @@ -497,7 +497,7 @@ The existing dashboard file-client maps include Cline CLI and reuse its committe Native Chat applies qualifying effort ceilings independently of model pins; pin selection precedes the cap and only pins or cap rewrites enter wire mapping. The [catalog effort contract](catalog.md#ultra-reasoning-level) records the V1/compaction exemptions and caller-preservation boundary. Pool quota producers and account commands follow the [bounded raw-observation contract](providers/openai-accounts.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. The account history response can include a [low-confidence effective capacity estimate](providers/openai-accounts.md#observed-effective-token-capacity); usage normalization retains local-answer provenance so local responses cannot supply samples. Account quota surfaces use [safe probe diagnostics](transports/inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. -Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](transports/responses.md) documents this boundary, and native Chat capability stripping applies only for an explicit empty declaration or no-reasoning model; an initial JEV null choice separately strips caller effort. +Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](transports/responses.md) documents this boundary, and native Chat also honors explicit per-axis capability declarations; an initial JEV null choice separately strips caller effort. Live sideband admission and its bounded upstream handshake follow the [runtime contract](runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. Dashboard overview polling observes authorization failures independently of stalled or rejected peer requests, cancels remaining child requests after a decisive result, and exposes resource-level deadline failures without rewriting them as authentication failures. diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index 939b2f1220e..4f0961dcb1d 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -193,7 +193,7 @@ Pool quota producers and account commands follow the [bounded raw-observation co Account quota surfaces use [safe probe diagnostics](../transports/inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. -Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](../transports/responses.md) documents this boundary, and native Chat capability stripping applies only for an explicit empty declaration or no-reasoning model; an initial JEV null choice separately strips caller effort. +Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](../transports/responses.md) documents this boundary, and native Chat also honors explicit per-axis capability declarations; an initial JEV null choice separately strips caller effort. Live sideband admission and its bounded upstream handshake follow the [runtime contract](../runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 4e24d97a203..e6dd2008d94 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -1,5 +1,7 @@ # Inbound Compatibility Surfaces +Thinking enablement is separate from strength: `src/responses/schema.ts` accepts the OCX `reasoning.enabled` boolean and `src/responses/parser.ts` retains it as `options.thinkingEnabled`, independently of `options.reasoning`. Chat `thinking.type` and Anthropic `thinking.type` preserve this choice through their inbound translators; the existing Anthropic disabled-effort sentinel remains for legacy destinations. `src/clients/reasoning-controls.ts` decodes effort-only client aliases for declared toggle-only models before strength caps run. Route fallback restores the original choice alongside effort in `src/server/effort-policy.ts`; a cleared strength list does not clear a thinking switch. Regression coverage lives in `tests/responses/responses-parser.test.ts`, `tests/responses/chat-inbound-reasoning-none.test.ts`, `tests/claude-integration/claude-inbound.test.ts`, and `tests/codex-integration/effort-policy.test.ts`. + The names used for these paths (native, translated, legacy bridge) and the declared per-feature dispositions are owned by [Protocol Paths](protocol-paths.md). @@ -141,14 +143,10 @@ parallel tools (or pinned false by the existing provider opt-out contract). A `reasoning_details` entry carrying Anthropic state — the `anthropic-claude-v1` marker, or an unmarked `reasoning.text` whose signature passes the inbound spelling check — makes the request ineligible for native passthrough so its signature or redacted ciphertext cannot cross into -another provider; the bridge keeps only the plaintext projection. Other provider formats -retain their native wire shape. The native -passthrough still applies the existing model capability authority to reasoning: an -explicit empty ladder removes caller `reasoning_effort`, while an unknown ladder remains -unclassified. The two Chat builders share the explicit wire policy after provider resolution: +another provider; the bridge keeps only the plaintext projection. Other provider formats retain their native wire shape. +The native passthrough applies the [independent reasoning capability contract](../providers-and-adapters.md): explicit false declarations constrain their respective controls, and an empty strength ladder removes caller `reasoning_effort` without disabling a declared thinking switch. Unknown axes remain unclassified. The two Chat builders share the explicit wire policy after provider resolution: `reasoningWireFormat: "gateway-object"` projects the configured object shape, and a listed -tool-bearing model omits reasoning effort on both paths. With neither declaration, native raw -reasoning forwarding stays unchanged. This guard does not alter the separate raw service-tier contract. +tool-bearing model omits reasoning effort on both paths. For explicitly declared independent controls on a toggle wire, `reasoning.enabled` wins, then caller raw `thinking`, then aliases/defaults; unknown or adaptive raw thinking shapes remain intact. With no applicable declaration, native raw reasoning forwarding stays unchanged. This guard does not alter the separate raw service-tier contract. On the response side, the upstream `service_tier` echo (xAI Priority Processing, OpenAI fast tier) relays to the Chat Completions caller on every delivery shape: the non-streaming body diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 10405f12f68..0fadbc20951 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -567,7 +567,7 @@ The account history response can include a [low-confidence effective capacity es Account quota surfaces use [safe probe diagnostics](../transports/inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. -Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](../transports/responses.md) documents this boundary, and native Chat removes effort only for an explicit empty declaration or no-reasoning model. +Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](../transports/responses.md) documents this boundary, and native Chat also honors explicit per-axis capability declarations. Translated Chat request construction uses the [inline-image budget](../transports/streaming-health.md#translated-chat-inline-image-budget); the shared normalizer counts retained bytes even when a wire-specific drop callback keeps the image attached, rejects inputs above the safe decoded-pixel ceiling, caps native decode work process-wide, and stops queued work when the request is cancelled. diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index e0976d7eb26..3f71bb3aa3e 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -381,7 +381,7 @@ Pool quota producers and account commands follow the [bounded raw-observation co Account quota surfaces use [safe probe diagnostics](../transports/inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. -Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](../transports/responses.md) documents this boundary, and native Chat capability stripping applies only for an explicit empty declaration or no-reasoning model; an initial JEV null choice separately strips caller effort. +Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](../transports/responses.md) documents this boundary, and native Chat also honors explicit per-axis capability declarations; an initial JEV null choice separately strips caller effort. Live sideband admission and its bounded upstream handshake follow the [runtime contract](../runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 566cd1bb9eb..29ffb01290b 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -1,4 +1,8 @@ # Providers And Adapters + +Reasoning support, thinking on/off control, real reasoning strengths and the thinking default are independent optional boolean axes in `ReasoningCapabilities`, flattened into `ModelCapabilities` and `CatalogModel`. `src/providers/reasoning-capabilities.ts` resolves fixed destination registry facts beneath operator declarations matched by case-insensitive model ID (without suffix or family matching); absent axes stay unknown and explicit false wins. `thinkingToggleModels` declares wire syntax, including adaptive-only protocols, and never implies an independent on/off capability. Legacy effort declarations infer real strength support only when their effective mapped values include genuine strength levels; enabled/disabled/adaptive aliases retain their existing controls without establishing strength support. Inferred strength support does not opt a legacy toggle serializer into sending an additional effort field; that requires an explicit model capability declaration. Legacy declarations still apply unless `supportsReasoningEffort: false`; an empty effort list does not disable the thinking toggle. `noReasoningModels` suppresses routed reasoning controls; native Codex forwarding keeps its snapshot-owned effort clamp. Captured model policy resolves the same axes without rereading the live registry. [Config validation and merge](config.md#explicit-per-model-capability-declarations) preserve each boolean independently. Native Claude family rules in `src/adapters/anthropic-model-contract.ts` outrank generic toggle serialization: adaptive thinking, the Sonnet 5.5 `between_tools` floor, supported explicit disable and budget/output constraints remain authoritative. + +Public MiMo V2.6 is a registry instance with reasoning and toggle support, no real effort strengths, and thinking enabled by default. `src/providers/mimo-thinking.ts` supplies those facts; `thinkingToggleModels` separately declares its existing `thinking.type` wire protocol. Client compatibility alone projects the switch to legacy `none/high` choices. Internal metadata contains no synthetic MiMo effort ladder or high default. Public model rows expose optional `supports_reasoning`, `supports_thinking_toggle`, `supports_reasoning_effort` and `default_thinking_enabled` flags; chained provider discovery reads those flags back without inferring false from absence. Config hints rederive cached projections from their original discovered reasoning metadata. Free, token-plan and third-party MiMo routes keep their existing contracts. Canonical key-auth Mistral Chat at `https://api.mistral.ai/v1` defaults to a patient bounded same-key 429 policy (six replays, ten-second fallback) in `src/providers/key-failover.ts`; explicit retry settings/opt-out win. The Mistral preset forwards supplied prompt-cache keys unless explicitly disabled. `src/providers/key-failover.ts` and transport selection use the same canonical-destination check, including custom provider names. `src/providers/mistral-transport.ts` shares explicit Retry-After deadlines across calls by actual credential digest and exact model, with bounded memory/waiters and cancellation. Without Retry-After, only a real 429 with validated exhausted minute capacity or a measured query charge above remaining but within the full token allowance activates a conservative 60-second recovery hold. This local policy does not claim the server reset instant. One probe gates concurrent recovery callers; a successful probe clears recovery unless a newer refusal intervened. Healthy traffic never predicts future query/cache cost; unknown, zero-entitlement, exhausted-monthly and above-full-allowance evidence does not invent a minute reset. Oversized Retry-After stops same-key replay rather than shortening the server delay: a same-target retry hold in `src/lib/upstream-retry.ts` is checked only by the same-key 429 loops, so key rotation and the original upstream status, body and Retry-After stay available. Either `requestPacing.enabled: false` or `retryOn429.respectRetryAfter: false` disables the shared hold. `tests/providers/mistral-recovery.test.ts` covers GLM/Large 4, destination/key/model isolation, absent headers, cache affinity, streamed/buffered replay, cancellation and send ceilings. There is no mid-stream replay or automatic compaction change. GCP JSON storage in `src/providers/api-keys.ts` and durable-row snapshot/post-commit cleanup in `src/providers/gcp-credential-cleanup.ts` follow [Vertex credential markers](providers/google.md#vertex-credential-markers); malformed blank marker accounts do not block cleanup of valid accounts. Combo target `metered` metadata and configured-key opt-in follow the [runtime consent contract](runtime.md#request-local-target-compatibility). Subscription-to-Cursor-to-key regression coverage in `tests/adapters/anthropic/anthropic-subscription-chain.test.ts` checks unchanged healthy subscription wire bodies, effort, key authentication and cache controls, and structured runTurn 429 propagation. OpenCode Go's exact Gemini pin and credential destination guard follow the [Go Gemini transport contract](providers/google.md#opencode-go-gemini-transport). Configured spend ceilings bind adapter inference executors to the [seed and physical-start contract](transports/responses-spend.md#historical-pool-continuity-and-rollback). Zed completion and auth-recovery replays claim separately; token acquisition stays outside inference accounting. Claude CLI, CodeBuddy and Qoder each count one invocation toward the frozen request limit, seed before spawn and retain a producer through completion. Child-internal retries/tool turns are not separately counted; reported actual usage settles in full, including amounts above the seed estimate. @@ -196,7 +200,7 @@ rewrite rules and the routed-id settlement. | `src/oauth/` | OAuth providers, token storage, refresh, and auth-token resolution. Meta Muse device authorization, polling, and key-mint JSON responses share the 64 KiB bounded-body ceiling and the request's deadline; oversized declared or streamed bodies are rejected before JSON parsing. The login callback listener binds a per-provider FIXED loopback port, so consecutive logins reuse the same number; every response it sends ends its connection (`Connection: close`, including non-callback paths such as a stray `/favicon.ico` 404). Stopping the listener does not close an established socket, so without that a pooled client would deliver the next login's callback to the retired flow, which rejects the unknown state as a CSRF mismatch while the live flow waits. Command Code manual callback JSON remains opaque to the shared `code#state` parser and is state-validated by its provider parser. A raw Command Code paste with an explicit `#state` suffix must match the flow state on the direct prompt as well. Kiro add-account identity prefers same-session `whoami` over a leftover SQLite state profile, and never persists the Builder ID service profile ARN as `accountId`. That identity is read from the leading JSON object of `kiro-cli whoami --format json`, which must end its line: kiro-cli 2.32 prints a human-readable `Profile:` block after it once an IAM Identity Center profile is selected, and that later text is ignored. Because an organization profile ARN is shared by its users, `src/oauth/kiro-account-identity.ts` keys Kiro rows by ARN plus the same-session `whoami` email (#6641): a login without a user keeps the legacy ARN identity but never replaces an identified user of the same profile unless the refresh token proves the same CLI session; reauth of a row with a user and rotated-CLI recovery require that same user (a row without a user recovers only while no other row shares its profile, and explicit reauth may record its user unless that user owns another row of the profile), an unmatched login claims an identity-less active slot only as the same session while the profile has other rows, and Add account records the current CLI user on ARN-only rows through a fenced same-session bind. ChatGPT token exchange and refresh in `src/oauth/chatgpt.ts` compose caller cancellation with a fresh 30-second per-fetch deadline covering headers and body. Error bodies are capped at 16 KiB bytes and success JSON at 64 KiB; each settled call detaches its caller listener and clears its timer. The registration in `src/oauth/index.ts` preserves that signal and uses the shared Codex classifier through `ChatGptTokenError`: terminal grant refusals require an explicit allowlisted terminal OAuth code and mark only the matching generation `needsReauth`; free-text descriptions never establish terminality; transient failures remain retryable. HTTP-derived errors expose only status and allowlisted code. Cancellation and deadline errors use fixed AbortError/TimeoutError messages; transport and parsing failures also use fixed messages, without raw reasons or causes, and never mark reauthentication. The registry preserves its own stale-flight error. `tests/oauth/chatgpt-token-expiry.test.ts`, `tests/oauth/chatgpt-token-boundaries.test.ts` and `tests/oauth/oauth-refresh-generic-lock.test.ts` cover these boundaries with synthetic responses. | | `src/combos/failover.ts` | Recognizes the exact bounded ChatGPT plan/model HTTP400 refusal in a bare message or a single `detail`/`error.message` envelope. Competing envelopes stop before code-based hops; finite evidence survives display truncation and SSE error reconstruction without retaining raw bodies. This permits another declared target before output commitment, never credential revocation or provider-wide quota evidence. | -| `src/combos/request.ts` | Clones each selected combo target request and applies the existing target capability ladder: adaptive unknown targets and explicit empty ladders receive no unsupported reasoning/thinking controls, while known ladders retain per-target resolution. | +| `src/combos/request.ts` | Clones each selected combo target request and applies the existing target capability ladder: adaptive unknown targets and explicit empty ladders strip effort and legacy alternative controls while retaining independent `reasoning.enabled` and summary; known ladders retain per-target resolution. | | `src/adapters/openai-responses.ts`, `src/adapters/openai-responses/` | Native OpenAI/ChatGPT Responses passthrough. Compaction parsing normalizes known incomplete aliases under the [incomplete reason contract](transports/responses-wire-shapes.md#adapter-incomplete-reasons). The canonical ChatGPT adapter forces its upstream-only `stream: true` requirement without changing caller `store`; downstream JSON negotiation remains owned by the [Responses HTTP/SSE contract](transports/responses.md#responses-httpsse). | | `src/adapters/openai-responses/transport.ts` | Shared Responses URL, configured auth and caller-header construction for ordinary requests and explicitly enabled custom native v1 compact. Its compact-handoff metadata copier uses the existing client-header allowlist, never credentials, through internal request construction and Codex auth materialization. Protocol capability never grants canonical identity; v2 trigger and native blob replay remain independently declared. On the noncanonical forward path, configured `baseUrl` trailing-slash stripping is a linear scan that preserves interior slash runs and leaves the canonical forward base and non-forward auth modes untouched; `tests/responses/responses-forward-transport-baseurl.test.ts` pins it. | | `src/responses/muse-tool-name-alias.ts` | Host-gated Meta Muse 64-char tool-name alias/restore used by the Responses passthrough. | @@ -591,10 +595,4 @@ Presence of both `detail` and `error` across the original root and its own non-a For complete bounded HTTP errors, the text wrapper selects root `detail`/`error` when present; otherwise it selects the own `response` record for the same exact `detail` or `error.message` match. It never searches another carrier after a nonmatch or descends further. Policy-fallback and direct callers share this wrapper; SSE message parsing disables nested HTTP selection. The same bounded HTTP parser retains allowlisted dedicated `code` fields from root and response before display truncation; diagnostic `type` is never promoted, including when code is absent or malformed. Policy/direct classification inspects full text only without authoritative carrier metadata; SSE never borrows hard codes from quoted message JSON. Committed output, cancellation, hard policy refusals and resend limits remain stronger than model fallback. Opt-in all-cooled target recovery in `src/combos/resolve.ts` follows [the Combo cooldown contract](runtime.md#scoped-provider-quota-for-combo-selection) without widening account/provider admission. It clears only request-eligible target cooldowns; route-level selection (including an unmetered reroute) and in-flight cooldown retries cannot initiate a reprobe. Encrypted-task recovery preserves request-specific JEV, reasoning-replay and protocol eligibility while relaxing payload readability. -Token Plan dated-model policy (2026-10-20): both curated presets include exact -`deepseek-v4.1-flash-1017` metadata without changing live discovery or Responses pins. -The registry may seed `noStructuredOutputModels` at route resolution, unioned with -operator opt-outs, just as it seeds `noJsonSchemaModels`. The full opt-out wins on -both translated and native Chat paths. For 1017 this deliberately omits JSON requests: -strict schemas are rejected and a `json_object` fallback has not been verified. -The bare model's independently verified downgrade remains separate. Token Plan startup refreshes only exact previous registry rosters for both presets, adding dated models and removing retired International IDs. International rows refresh only on the Token Plan host or the registry default; pay-as-you-go and Custom destinations keep their lists. Edited or reordered lists, saved model defaults and existing per-model window, output-limit and effort overrides remain untouched; an added ID is backfilled only into such a map the row already has, and a row already on the current roster gets only its missing output limits, because custom catalog rows read the saved output-limit map directly. Destination-based enrichment of custom-named rows fills per-model effort defaults, effort maps and structured-output restrictions from the matching registry destination or alias, and operator values win. Both Token Plan presets keep the hosts their official quick starts document; International custom-name enrichment remains excluded by its existing override guard. +Token Plan dated-model policy (2026-10-20): both curated presets include exact `deepseek-v4.1-flash-1017` metadata without changing live discovery or Responses pins. The registry may seed `noStructuredOutputModels` at route resolution, unioned with operator opt-outs, just as it seeds `noJsonSchemaModels`. The full opt-out wins on both translated and native Chat paths. For 1017 this deliberately omits JSON requests: strict schemas are rejected and a `json_object` fallback has not been verified. The bare model's independently verified downgrade remains separate. Token Plan startup refreshes only exact previous registry rosters for both presets, adding dated models and removing retired International IDs. International rows refresh only on the Token Plan host or the registry default; pay-as-you-go and Custom destinations keep their lists. Edited or reordered lists, saved model defaults and existing per-model window, output-limit and effort overrides remain untouched; an added ID is backfilled only into such a map the row already has, and a row already on the current roster gets only its missing output limits, because custom catalog rows read the saved output-limit map directly. Destination-based enrichment of custom-named rows fills per-model effort defaults, effort maps and structured-output restrictions from the matching registry destination or alias, and operator values win. Both Token Plan presets keep the hosts their official quick starts document; International custom-name enrichment remains excluded by its existing override guard. diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 34e9dfae339..19ec983ed38 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -111,11 +111,7 @@ Passthrough and routed-compaction raw-body repairs emit it as a user turn (`exte Stateful developer-guidance injection reuses that validator for its raw insertion boundary, so parsed messages and stored raw history retain the same task/guidance order. -Native OpenAI passthrough consults the existing configured capability ladder before forwarding -`reasoning_effort`; an explicitly empty ladder removes that unsupported control while an unknown -ladder remains unclassified. Both Chat builders then apply the same explicit provider declarations: -gateway-object projection and tool-bearing model effort omission. An unset declaration preserves the -native caller field exactly. Native passthrough also sanitizes routed reasoning history so `reasoning` input items do not send +Native Chat passthrough and translated Chat share `src/adapters/openai-chat/reasoning-wire.ts` after provider resolution. Explicit reasoning/effort false declarations and empty strength ladders remove unsupported effort; toggle false removes the switch without declaring an unknown strength axis unsupported. A declared toggle wire serializes the independent thinking choice without treating enabled/disabled/adaptive discriminators as strengths. Dual-control requests still pass through gateway-object projection and tool-bearing effort omission; a legacy toggle configuration gains a second serialized control only with explicit effort support; unknown declarations preserve legacy raw forwarding. Native passthrough also sanitizes routed reasoning history so `reasoning` input items do not send non-empty `content` arrays to upstream models that reject them. Chat Completions bridging repairs orphan `toolResult` messages by inserting a synthetic assistant `tool_call` before tool messages. It also repairs the opposite direction (260718): an assistant `tool_calls` round left dangling — @@ -464,8 +460,7 @@ measurement rather than allocating a serialized copy just to measure it. > Decision record: [ADR-0067](../decisions/ADR-0067-reasoning-display-parity-hidethinkingsummary.md) -`hideThinkingSummary` is set for explicit summary "none", or omitted summary without a validated -active effort. Accepted minimal/low/medium/high/xhigh/max (including ultra normalized to max) +`hideThinkingSummary` is set for explicit summary "none", or omitted summary without active thinking. An explicit `thinkingEnabled` boolean determines activity before effort is considered; otherwise accepted minimal/low/medium/high/xhigh/max (including ultra normalized to max) allow raw visibility when summary is omitted; none and invalid efforts do not. Explicit "auto" still permits raw visibility independently of effort. This flag is honored by BOTH reasoning paths: anthropic `thinking_delta` AND raw `reasoning_raw_delta` diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index eeb5576f462..e7434dfb33d 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -283,7 +283,7 @@ Canonical Spark Lite metadata follows the final serialized model and surviving n Shared raw-reasoning events retain content-channel presentation; provider-authored thinking keeps its existing summary path. See [bridge contract](chat-compat.md). -Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](../transports/responses.md) documents this boundary, and native Chat removes effort only for an explicit empty declaration or no-reasoning model. +Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](../transports/responses.md) documents this boundary, and native Chat also honors explicit per-axis capability declarations. ## Textual pseudo tool-call quarantine diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index bc4df0c5c33..460238e5898 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -217,7 +217,7 @@ do not alter xAI discovery, model metadata, or transport behavior. Native Chat applies qualifying effort ceilings independently of model pins; pin selection precedes the cap and only pins or cap rewrites enter wire mapping. The [catalog effort contract](../catalog.md#ultra-reasoning-level) records the V1/compaction exemptions and caller-preservation boundary. -Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](../transports/responses.md) documents this boundary, and native Chat capability stripping applies only for an explicit empty declaration or no-reasoning model; an initial JEV null choice separately strips caller effort. +Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](../transports/responses.md) documents this boundary, and native Chat also honors explicit per-axis capability declarations; an initial JEV null choice separately strips caller effort. The [explicit model-capability contract](../config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. diff --git a/structure/subagents.md b/structure/subagents.md index 9debfc3509b..0cda574b70e 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -516,7 +516,7 @@ The account history response can include a [low-confidence effective capacity es Account quota surfaces use [safe probe diagnostics](transports/inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. -Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](transports/responses.md) documents this boundary, and native Chat capability stripping applies only for an explicit empty declaration or no-reasoning model; an initial JEV null choice separately strips caller effort. +Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](transports/responses.md) documents this boundary, and native Chat also honors explicit per-axis capability declarations; an initial JEV null choice separately strips caller effort. Live sideband admission and its bounded upstream handshake follow the [runtime contract](runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. diff --git a/structure/transports/responses-failover.md b/structure/transports/responses-failover.md index 365cb968c8a..445b8a6c8a2 100644 --- a/structure/transports/responses-failover.md +++ b/structure/transports/responses-failover.md @@ -147,8 +147,8 @@ cooldown is live. A stale-generation refusal cannot borrow a sibling request's s `src/server/responses/core.ts` passes the combo's `reasoningEffortMode` and the final target's `supportedLadderFor` result to `src/combos/request.ts` before adapter parsing. Explicit empty -capability ladders remove effort and thinking controls in every combo mode; adaptive mode also -removes those controls for unknown ladders and preserves `reasoning.summary`. Known non-empty +capability ladders remove effort and legacy alternative thinking controls in every combo mode; adaptive mode also +removes those controls for unknown ladders. Independent `reasoning.enabled` and `reasoning.summary` survive. Known non-empty ladders retain the existing per-target effort resolution. This request normalization does not change target order or attempt accounting; provider-400 decisions follow the [request-local target compatibility](../runtime.md#request-local-target-compatibility) contract. diff --git a/structure/transports/responses-wire-shapes.md b/structure/transports/responses-wire-shapes.md index 476f2267128..1177c119bca 100644 --- a/structure/transports/responses-wire-shapes.md +++ b/structure/transports/responses-wire-shapes.md @@ -569,7 +569,7 @@ An injected combo default supplies `summary: "auto"` only when no summary was sp summary choices remain intact. Raw display and hidden-envelope replay follow [reasoning display parity](../providers/chat-compat.md#reasoning-display-parity-hidethinkingsummary). Final-route normalization preserves visible raw reasoning when the parsed request has a validated -active effort and omits summary; explicit `summary: "none"` still hides it (passthrough strips that internal marker before the upstream send). +active thinking choice (an explicit `thinkingEnabled` boolean takes precedence over effort) and omits summary; explicit `summary: "none"` still hides it (passthrough strips that internal marker before the upstream send). The provider policy `hideRawReasoning` suppresses the raw `reasoning_raw_delta` channel only — openai-chat `reasoning_content`, kiro tags, and Gemini thought parts on routes that do not return thought summaries (direct and Vertex Gemini; a `cloud-code-assist` Gemini route emits its thought diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 00a688001bc..fa7ce38bb64 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -408,6 +408,8 @@ The continuation-note and continuation-wire tests establish prompt and wire cont ## Core module ownership +Final-route normalization in `src/server/responses/core-normalize.ts` decodes client effort aliases through `src/clients/reasoning-controls.ts` before strength pins/caps. `options.thinkingEnabled` is independent of `options.reasoning`: explicit booleans outrank aliases and defaults, and an empty strength ladder does not erase a declared switch. `src/server/effort-policy.ts` restores both original choices on a destination change; credential-only retries retain the current normalization. Routed capability normalization removes unsupported effort from both parsed options and the raw request. Native Codex requests retain snapshot-owned effort clamping even with saved capability exclusions. Unsupported routed controls follow the [per-axis capability contract](../providers-and-adapters.md), while unknown axes retain legacy behavior. At serialization, `src/adapters/openai-responses/passthrough.ts` strips the OCX `reasoning.enabled` extension for both HTTP and WebSocket bodies unless a custom destination declares `reasoningWireFormat: "gateway-object"`; OpenAI-operated destinations always strip it. The extension-removal step leaves other reasoning members untouched; existing effort and summary normalization still applies. + `src/server/responses/core.ts` is the public ingress and compatibility-export surface. The parent `src/server/responses.ts` facade retains its existing imports. Per-request execution is composed from the following owners in `src/server/responses/`; none is a generated artifact. | Owner | Responsibility | @@ -431,9 +433,7 @@ The continuation-note and continuation-wire tests establish prompt and wire cont | `adapter-dispatch.ts` | Translated initial dispatch, bounded recovery (including [Anthropic thinking-signature recovery](anthropic-thinking-recovery.md)) and the shared continuation retry counter. Initial and rebuilt adapter budget refusals retain the HTTP 413 [byte-accounting contract](byte-accounting.md#request-copy-accounting); ordinary build errors remain 400. | | `adapter-continuation.ts`, `adapter-delivery.ts` | Continuation event sources and final streaming/buffered bridging; Anthropic routed summaries pass through `src/responses/compaction-summary.ts` before either bridge. A streamed turn with a `clientEncoder` option is handed to `src/server/inference/client-encoder-delivery.ts` instead of the bridge. | -Reusable helpers live in `core-auth.ts`, `core-codex-account.ts`, `core-combo.ts`, -`core-combo-failure.ts`, `core-combo-native.ts`, `combo-requested-effort.ts`, `core-errors.ts`, `core-lifetime.ts`, `core-normalize.ts`, -`core-opaque-recovery.ts` and `core-replay.ts`. `core-options.ts` owns the public option types +Reusable helpers live in `core-auth.ts`, `core-codex-account.ts`, `core-combo.ts`, `core-combo-failure.ts`, `core-combo-native.ts`, `combo-requested-effort.ts`, `core-errors.ts`, `core-lifetime.ts`, `core-normalize.ts`, `core-opaque-recovery.ts` and `core-replay.ts`. `core-options.ts` owns the public option types and small composition contracts, including [finite model-refusal evidence](../providers-and-adapters.md#combo-model-refusal-evidence). Existing public helper names are re-exported by `core.ts`. After an admitted Anthropic OAuth pool rotation, `adapter-dispatch.ts` rebinds reasoning replay to the committed credential snapshot; `adapter-continuation.ts` rebinds both its continuation clone and the outer request that persists the response. The replacement send strips the previous account's private state, while persisted ownership and the next turn follow the serving account. Codex alternate-account sends in `core-codex-account.ts` record `oauth-account-402` for HTTP 402 quota recovery, `oauth-account-429` for HTTP 429 rate-limit recovery, and `transient-5xx` for transient recovery, preserving the first response's wire status when sealing its attempt; gated-model moves keep their existing unattributed kind. Adapter construction remains with the existing registry; `fetch-helpers.ts` remains a leaf. For Kiro OAuth with load settings, `request-transport.ts` acquires a lease on the admitted account and transfers it before a reactive replacement send; cancellation permanently fences the request holder so recovery cannot install a late lease after abort cleanup. `core.ts` and `core-lifetime.ts` release the lease on returned-body completion, error, or cancellation, outside the inner admission `finally`. diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index c2812203286..9f005af61cb 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -358,7 +358,7 @@ Pool quota producers and account commands follow the [bounded raw-observation co Account quota surfaces use [safe probe diagnostics](inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. -Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](responses.md) documents this boundary, and native Chat capability stripping applies only for an explicit empty declaration or no-reasoning model; an initial JEV null choice separately strips caller effort. +Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](responses.md) documents this boundary, and native Chat also honors explicit per-axis capability declarations; an initial JEV null choice separately strips caller effort. Live sideband admission and its bounded upstream handshake follow the [runtime contract](../runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. diff --git a/tests/adapters/anthropic/anthropic-reasoning.test.ts b/tests/adapters/anthropic/anthropic-reasoning.test.ts index 8ae1be22a2a..23f5724ef63 100644 --- a/tests/adapters/anthropic/anthropic-reasoning.test.ts +++ b/tests/adapters/anthropic/anthropic-reasoning.test.ts @@ -26,6 +26,158 @@ async function bodyOf(p: OcxParsedRequest, configuredProvider = provider): Promi } describe("anthropic extended-thinking gate", () => { + test.each([[false, "disabled"], [true, "enabled"]] as const)("toggle-only models serialize direct boolean %s as %s", async (thinkingEnabled, type) => { + const modelId = "toggle-only-model"; + const request = parsed(undefined, { temperature: 0.3, topP: 0.9 }, modelId); + request.options.thinkingEnabled = thinkingEnabled; + const b = await bodyOf(request, { + ...provider, + thinkingToggleModels: [modelId], + modelCapabilities: { [modelId]: { + supportsReasoning: true, + supportsThinkingToggle: true, + supportsReasoningEffort: false, + defaultThinkingEnabled: true, + } }, + }); + expect(b.thinking).toEqual({ type }); + expect(b.output_config).toBeUndefined(); + expect(b.max_tokens).toBe(8192); + expect(b.temperature).toBe(0.3); + expect(b.top_p).toBe(0.9); + }); + + test("toggle-only models apply their capability default without adding strength fields", async () => { + const modelId = "toggle-only-default"; + const b = await bodyOf(parsed(undefined, {}, modelId), { + ...provider, + thinkingToggleModels: [modelId], + modelCapabilities: { [modelId]: { + supportsReasoning: true, + supportsThinkingToggle: true, + supportsReasoningEffort: false, + defaultThinkingEnabled: true, + } }, + }); + expect(b.thinking).toEqual({ type: "enabled" }); + expect(b.output_config).toBeUndefined(); + expect(b.max_tokens).toBe(8192); + }); + + test("models with genuine effort strengths retain their effort wire", async () => { + const modelId = "ordinary-strength-model"; + const b = await bodyOf(parsed("high", {}, modelId), { + ...provider, + baseUrl: "https://gateway.example.test/anthropic", + modelCapabilities: { [modelId]: { + supportsReasoning: true, + supportsThinkingToggle: true, + supportsReasoningEffort: true, + defaultThinkingEnabled: true, + } }, + }); + expect(b.thinking).toEqual({ type: "enabled", budget_tokens: 16384 }); + }); + + test("an explicit toggle off wins over a genuine high effort", async () => { + const modelId = "toggle-dual-axis"; + const request = parsed("high", { temperature: 0.3, topP: 0.9, thinkingEnabled: false }, modelId); + const b = await bodyOf(request, { + ...provider, + thinkingToggleModels: [modelId], + modelCapabilities: { [modelId]: { + supportsReasoning: true, + supportsThinkingToggle: true, + supportsReasoningEffort: true, + defaultThinkingEnabled: true, + } }, + }); + expect(b.thinking).toEqual({ type: "disabled" }); + expect(b.output_config).toBeUndefined(); + expect(b.max_tokens).toBe(8192); + expect(b.temperature).toBe(0.3); + expect(b.top_p).toBe(0.9); + }); + + test("an explicitly unsupported capability suppresses direct Anthropic controls", async () => { + const modelId = "unsupported-reasoning"; + const request = parsed("high", { thinkingEnabled: true }, modelId); + const b = await bodyOf(request, { + ...provider, + thinkingToggleModels: [modelId], + modelCapabilities: { [modelId]: { supportsReasoning: false } }, + }); + expect(b.thinking).toBeUndefined(); + expect(b.output_config).toBeUndefined(); + }); + + test.each([ + "claude-sonnet-5-5", "claude-opus-4-8", "claude-haiku-5-5", "claude-fable-5", + ])("%s keeps its native contract with a declared generic toggle", async (modelId) => { + const target = { + ...provider, + thinkingToggleModels: [modelId], + modelCapabilities: { [modelId]: { + supportsReasoning: true, supportsThinkingToggle: true, supportsReasoningEffort: false, + } }, + }; + const on = await bodyOf(parsed("high", {}, modelId), target); + expect(on.thinking).toEqual({ type: "adaptive", display: "summarized" }); + expect(on.output_config).toEqual({ effort: "high" }); + const off = await bodyOf(parsed("none", { maxOutputTokens: 64 }, modelId), target); + expect(off.thinking).toEqual(modelId === "claude-sonnet-5-5" ? { type: "between_tools" } + : modelId === "claude-haiku-5-5" ? { type: "disabled" } : undefined); + expect(off.output_config).toBeUndefined(); + expect(off.max_tokens).toBe(64); + const explicitOff = await bodyOf(parsed("high", { thinkingEnabled: false, maxOutputTokens: 64 }, modelId), target); + expect(explicitOff.thinking).toEqual(off.thinking); + expect(explicitOff.output_config).toBeUndefined(); + expect(explicitOff.max_tokens).toBe(64); + const explicitOn = await bodyOf(parsed(undefined, { thinkingEnabled: true }, modelId), target); + expect(explicitOn.thinking).toEqual(on.thinking); + }); + + test.each([ + ["claude-haiku-4-5", true, undefined], + ["claude-sonnet-4-6", true, undefined], + ["claude-opus-4-6", true, undefined], + ["claude-haiku-4-5", false, "high"], + ["claude-sonnet-4-6", false, "high"], + ["claude-opus-4-6", false, "high"], + ] as const)("%s preserves native budget thinking (effort support=%s, effort=%s)", async (modelId, supportsReasoningEffort, reasoning) => { + const target = { + ...provider, + thinkingToggleModels: [modelId], + modelCapabilities: { [modelId]: { + supportsReasoning: true, supportsThinkingToggle: true, supportsReasoningEffort, + } }, + }; + const options = { temperature: 0.3, topP: 0.9, ...(supportsReasoningEffort ? { thinkingEnabled: true } : {}) }; + const on = await bodyOf(parsed(reasoning, options, modelId), target); + expect(on.thinking).toEqual({ type: "enabled", budget_tokens: 16384 }); + expect(on.max_tokens).toBe(24576); + expect(on.output_config).toBeUndefined(); + expect(on.temperature).toBeUndefined(); + expect(on.top_p).toBeUndefined(); + // Explicit off must still win without allocating a thinking budget or increasing the limit. + const off = await bodyOf(parsed("high", { thinkingEnabled: false, maxOutputTokens: 64 }, modelId), target); + expect(off.thinking).toBeUndefined(); + expect(off.output_config).toBeUndefined(); + expect(off.max_tokens).toBe(64); + }); + + test("both unsupported axes suppress native thinking and token headroom", async () => { + const modelId = "claude-sonnet-5-5"; + const b = await bodyOf(parsed("high", { thinkingEnabled: true, maxOutputTokens: 64 }, modelId), { + ...provider, + thinkingToggleModels: [modelId], + modelCapabilities: { [modelId]: { supportsThinkingToggle: false, supportsReasoningEffort: false } }, + }); + expect(b.thinking).toBeUndefined(); + expect(b.output_config).toBeUndefined(); + expect(b.max_tokens).toBe(64); + }); + // Sonnet 4.5 takes temperature or top_p alone but 400s on both (live 2026-09-29), so a request // carrying both keeps temperature; each field alone survives. test("reasoning 'none' does NOT enable thinking and preserves temperature", async () => { diff --git a/tests/adapters/openai/openai-chat-reasoning-wire-policy.test.ts b/tests/adapters/openai/openai-chat-reasoning-wire-policy.test.ts index 5814291eef6..186b98ff394 100644 --- a/tests/adapters/openai/openai-chat-reasoning-wire-policy.test.ts +++ b/tests/adapters/openai/openai-chat-reasoning-wire-policy.test.ts @@ -4,6 +4,8 @@ import { createOpenAIChatAdapter, } from "../../../src/adapters/openai-chat"; import { chatCompletionsToResponsesBody } from "../../../src/chat/inbound"; +import { providerConfigSeed } from "../../../src/providers/derive"; +import { getProviderRegistryEntry } from "../../../src/providers/registry"; import { parseRequest } from "../../../src/responses/parser"; import type { OcxProviderConfig } from "../../../src/types"; @@ -37,6 +39,23 @@ const tool: Record = { }; const inputWithTool: Record = { ...input, tools: [tool] }; +const toggleModel = "toggle-only-model"; +const toggleProvider: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://gateway.example.test/v1", + apiKey: "sk-test", + authMode: "key", + thinkingToggleModels: [toggleModel], + modelCapabilities: { + [toggleModel]: { + supportsReasoning: true, + supportsThinkingToggle: true, + supportsReasoningEffort: false, + defaultThinkingEnabled: true, + }, + }, +}; + function finalBodies(rawInput: Record): Record<"translated" | "native", Record> { const parsed = parseRequest(chatCompletionsToResponsesBody(rawInput)); const translated = createOpenAIChatAdapter(provider).buildRequest(parsed); @@ -48,6 +67,25 @@ function finalBodies(rawInput: Record): Record<"translated" | " }; } +function finalToggleBodies( + rawInput: Record, + target: OcxProviderConfig = toggleProvider, + targetModel = toggleModel, + thinkingEnabled?: boolean, +): Record<"translated" | "native", Record> { + const requestForModel = { + ...rawInput, model: targetModel, + ...(thinkingEnabled === undefined ? {} : { reasoning: { enabled: thinkingEnabled } }), + }; + const parsed = parseRequest(chatCompletionsToResponsesBody(requestForModel)); + const translated = createOpenAIChatAdapter(target).buildRequest(parsed); + const native = buildOpenAIChatPassthroughRequest(target, requestForModel, targetModel, false); + return { + translated: JSON.parse(translated.body) as Record, + native: JSON.parse(native.body) as Record, + }; +} + describe("OpenAI Chat reasoning wire policy parity", () => { test("plain reasoning disable uses the gateway object on both final wires", () => { const bodies = finalBodies(input); @@ -82,3 +120,342 @@ describe("OpenAI Chat reasoning wire policy parity", () => { }); }); }); + +describe("toggle-only Chat reasoning capability", () => { + const request = (reasoning_effort?: string): Record => ({ + model: toggleModel, + messages: [{ role: "user", content: "think" }], + ...(reasoning_effort === undefined ? {} : { reasoning_effort }), + }); + + test.each([[false, "disabled"], [true, "enabled"]] as const)("serializes direct boolean %s as %s", (thinkingEnabled, type) => { + const bodies = finalToggleBodies(request(), toggleProvider, toggleModel, thinkingEnabled); + for (const body of Object.values(bodies)) { + expect(body.thinking).toEqual({ type }); + expect(body.reasoning_effort).toBeUndefined(); + } + }); + + test.each([[false, "enabled", "disabled"], [true, "disabled", "enabled"]] as const)( + "explicit reasoning.enabled %s wins over conflicting raw thinking %s", + (enabled, conflictingType, type) => { + for (const body of Object.values(finalToggleBodies({ + ...request(), reasoning: { enabled }, thinking: { type: conflictingType }, + }))) expect(body.thinking).toEqual({ type }); + }, + ); + + test("translates legacy Chat effort on both final wires", () => { + for (const body of Object.values(finalToggleBodies(request("none")))) { + expect(body.thinking).toEqual({ type: "disabled" }); + expect(body.reasoning_effort).toBeUndefined(); + } + }); + + test("uses the capability default and retains an explicit native toggle when compatibility input is omitted", () => { + const bodies = finalToggleBodies(request()); + expect(bodies.translated.thinking).toEqual({ type: "enabled" }); + expect(bodies.native.thinking).toEqual({ type: "enabled" }); + + const native = buildOpenAIChatPassthroughRequest(toggleProvider, { + ...request(), + thinking: { type: "disabled" }, + }, toggleModel, false); + expect((JSON.parse(native.body) as Record).thinking).toEqual({ type: "disabled" }); + }); + + test("an explicit empty effort list does not disable the independent toggle", () => { + for (const body of Object.values(finalToggleBodies(request(), { + ...toggleProvider, + modelReasoningEfforts: { [toggleModel]: [] }, + }))) { + expect(body.thinking).toEqual({ type: "enabled" }); + expect(body.reasoning_effort).toBeUndefined(); + } + }); + + test("an omitted legacy alias does not revive the toggle default", () => { + for (const body of Object.values(finalToggleBodies(request("high"), { + ...toggleProvider, + modelReasoningEffortMap: { [toggleModel]: { high: "__omit__" } }, + }))) { + expect(body.thinking).toBeUndefined(); + expect(body.reasoning_effort).toBeUndefined(); + } + }); + + test("an effort capability false strips native and translated strength without a toggle declaration", () => { + const modelId = "effort-disabled-model"; + const target: OcxProviderConfig = { + ...toggleProvider, + thinkingToggleModels: undefined, + modelCapabilities: { [modelId]: { supportsReasoning: true, supportsReasoningEffort: false } }, + }; + for (const body of Object.values(finalToggleBodies({ ...request("high"), reasoning: { enabled: true } }, target, modelId))) { + expect(body.thinking).toBeUndefined(); + expect(body.reasoning_effort).toBeUndefined(); + expect(body.reasoning).toBeUndefined(); + } + }); + + test("a toggle capability false retains a separately supported raw effort", () => { + const target: OcxProviderConfig = { + ...toggleProvider, + modelCapabilities: { [toggleModel]: { + supportsReasoning: true, + supportsThinkingToggle: false, + supportsReasoningEffort: true, + } }, + modelReasoningEffortMap: { [toggleModel]: { high: "enabled" } }, + }; + for (const body of Object.values(finalToggleBodies({ ...request("high"), thinking: { type: "enabled" } }, target))) { + expect(body.thinking).toBeUndefined(); + expect(body.reasoning_effort).toBe("high"); + } + }); + + test.each([ + [undefined, "medium", false, "medium", "high"], + [undefined, "enabled", false, "high", "high"], + [undefined, "__omit__", false, undefined, "high"], + [undefined, "medium", true, undefined, "high"], + [undefined, "enabled", false, "max", "ultra"], + ["gateway-object", "medium", false, "medium", "high"], + ["gateway-object", "enabled", false, "high", "high"], + ["gateway-object", "__omit__", false, undefined, "high"], + ["gateway-object", "medium", true, undefined, "high"], + ["gateway-object", "enabled", false, "max", "ultra"], + ] as const)("toggle-disabled effort keeps %s mapping %s with tools=%s for %s (%s)", (reasoningWireFormat, mapped, hasTools, expected, requested) => { + const target: OcxProviderConfig = { + ...toggleProvider, reasoningWireFormat, + modelCapabilities: { [toggleModel]: { + supportsReasoning: true, supportsThinkingToggle: false, supportsReasoningEffort: true, + } }, + modelReasoningEffortMap: { [toggleModel]: { [requested === "ultra" ? "max" : requested]: mapped } }, + omitReasoningEffortWithToolsModels: [toggleModel], + }; + const raw = { ...request(requested), thinking: { type: "enabled" }, ...(hasTools ? { tools: [tool] } : {}) }; + const results = [ + createOpenAIChatAdapter(target).buildRequest(parseRequest(chatCompletionsToResponsesBody(raw))), + buildOpenAIChatPassthroughRequest(target, raw, toggleModel, false), + ]; + for (const result of results) { + const body = JSON.parse(result.body) as Record; + expect(body.thinking).toBeUndefined(); + expect(body.reasoning_effort).toBe(reasoningWireFormat ? undefined : expected); + expect(body.reasoning).toEqual(reasoningWireFormat && expected ? { enabled: true, effort: expected } : undefined); + expect(result.reasoningLog).toEqual(expected ? { + effectiveEffort: expected, + wireField: reasoningWireFormat ? "reasoning.effort" : "reasoning_effort", + wireValue: expected, + } : undefined); + if (hasTools) expect(body.tools).toEqual([tool]); + } + }); + + test.each([ + ["reasoning disabled", { supportsReasoning: false }], + ["both controls disabled", { supportsThinkingToggle: false, supportsReasoningEffort: false }], + ])("an explicit capability with %s drops both control wires", (_label, overrides) => { + const modelCapabilities = { [toggleModel]: { ...toggleProvider.modelCapabilities![toggleModel]!, ...overrides } }; + for (const body of Object.values(finalToggleBodies({ ...request("high"), thinking: { type: "enabled" } }, { + ...toggleProvider, + modelCapabilities, + }))) { + expect(body.thinking).toBeUndefined(); + expect(body.reasoning_effort).toBeUndefined(); + expect(body.reasoning).toBeUndefined(); + } + }); + + test("noReasoningModels remains authoritative", () => { + for (const body of Object.values(finalToggleBodies(request("none"), { + ...toggleProvider, + noReasoningModels: [toggleModel], + }))) { + expect(body.thinking).toBeUndefined(); + expect(body.reasoning_effort).toBeUndefined(); + } + }); + + test("a declared dual-axis wire preserves explicit thinking alongside low effort", () => { + const bothProvider: OcxProviderConfig = { + ...toggleProvider, + modelCapabilities: { [toggleModel]: { + supportsReasoning: true, + supportsThinkingToggle: true, + supportsReasoningEffort: true, + defaultThinkingEnabled: true, + } }, + modelReasoningEfforts: { [toggleModel]: ["low"] }, + }; + const raw = { ...request("low"), thinking: { type: "enabled" } }; + const parsed = parseRequest(chatCompletionsToResponsesBody(raw)); + const translated = JSON.parse(createOpenAIChatAdapter(bothProvider).buildRequest(parsed).body) as Record; + const native = JSON.parse(buildOpenAIChatPassthroughRequest(bothProvider, raw, toggleModel, false).body) as Record; + for (const body of [translated, native]) { + expect(body.thinking).toEqual({ type: "enabled" }); + expect(body.reasoning_effort).toBe("low"); + } + }); + + test.each([ + [undefined, false], [undefined, true], + ["gateway-object", false], ["gateway-object", true], + ] as const)("dual controls retain %s effort policy with tools=%s", (reasoningWireFormat, hasTools) => { + const target: OcxProviderConfig = { + ...toggleProvider, reasoningWireFormat, + reasoningEfforts: ["low", "high"], + modelCapabilities: { [toggleModel]: { + supportsThinkingToggle: true, supportsReasoningEffort: true, defaultThinkingEnabled: true, + } }, + omitReasoningEffortWithToolsModels: [toggleModel], + }; + for (const thinkingEnabled of [undefined, false, true]) { + const bodies = finalToggleBodies({ + ...request("high"), ...(hasTools ? { tools: [tool] } : {}), + }, target, toggleModel, thinkingEnabled); + for (const body of Object.values(bodies)) { + expect(body.thinking).toEqual({ type: thinkingEnabled === false ? "disabled" : "enabled" }); + expect(body.reasoning_effort).toBe(!hasTools && !reasoningWireFormat ? "high" : undefined); + expect(body.reasoning).toEqual(!hasTools && reasoningWireFormat ? { enabled: thinkingEnabled !== false, effort: "high" } : undefined); + if (hasTools) expect(body.tools).toEqual([tool]); + } + } + }); + + test.each([ + [false, "high"], [true, "high"], [false, "none"], [true, "none"], + ] as const)("dual gateway enablement preserves explicit %s with effort %s", (thinkingEnabled, effort) => { + const target: OcxProviderConfig = { + ...toggleProvider, reasoningWireFormat: "gateway-object", + modelCapabilities: { [toggleModel]: { supportsThinkingToggle: true, supportsReasoningEffort: true } }, + reasoningEfforts: ["low", "high"], + }; + for (const body of Object.values(finalToggleBodies(request(effort), target, toggleModel, thinkingEnabled))) { + expect(body.thinking).toEqual({ type: thinkingEnabled ? "enabled" : "disabled" }); + expect(body.reasoning).toEqual({ enabled: thinkingEnabled, ...(effort === "high" ? { effort } : {}) }); + expect(body.reasoning_effort).toBeUndefined(); + } + }); + + test.each([false, true])("mapped independent toggle survives tool omission with effort support=%s", supportsReasoningEffort => { + const target: OcxProviderConfig = { + ...toggleProvider, + modelCapabilities: { [toggleModel]: { supportsThinkingToggle: true, supportsReasoningEffort } }, + modelReasoningEffortMap: { [toggleModel]: { high: "enabled" } }, + omitReasoningEffortWithToolsModels: [toggleModel], + }; + for (const thinkingEnabled of [false, true]) { + for (const body of Object.values(finalToggleBodies({ + ...request("high"), tools: [tool], + }, target, toggleModel, thinkingEnabled))) { + expect(body.thinking).toEqual({ type: thinkingEnabled ? "enabled" : "disabled" }); + expect(body.reasoning_effort).toBeUndefined(); + expect(body.reasoning).toBeUndefined(); + expect(body.tools).toEqual([tool]); + } + } + }); +}); + +describe("legacy mapped thinking discriminator wires", () => { + test.each([undefined, "gateway-object"] as const)("legacy toggle selection stays unchanged with %s wire", reasoningWireFormat => { + const target: OcxProviderConfig = { + ...toggleProvider, reasoningWireFormat, + modelCapabilities: undefined, + reasoningEfforts: ["low", "high"], + }; + const raw = { messages: [{ role: "user", content: "think" }], reasoning_effort: "high" }; + const bodies = finalToggleBodies(raw, target); + expect(bodies.translated.reasoning_effort).toBeUndefined(); + expect(bodies.native.reasoning_effort).toBe(reasoningWireFormat ? undefined : "high"); + for (const body of Object.values(bodies)) { + expect(body.thinking).toBeUndefined(); + expect(body.reasoning).toEqual(reasoningWireFormat ? { enabled: true, effort: "high" } : undefined); + } + }); + + test.each([ + ["zhipu-bigmodel", "glm-4.6", "enabled"], + ["opencode-go", "mimo-v2.5", "enabled"], + ["volcengine", "doubao-seed-2-1-pro-260628", "enabled"], + ["minimax", "MiniMax-M3", "adaptive"], + ] as const)("%s/%s translates %s without remapping the native wire", (providerId, modelId, type) => { + const entry = getProviderRegistryEntry(providerId); + if (!entry) throw new Error(`Missing registry seed ${providerId}`); + const target = { ...providerConfigSeed(entry), apiKey: "sk-test" }; + const raw = { model: modelId, messages: [{ role: "user", content: "think" }], reasoning_effort: "high" }; + const parsed = parseRequest(chatCompletionsToResponsesBody(raw)); + const translated = JSON.parse(createOpenAIChatAdapter(target).buildRequest(parsed).body) as Record; + const native = JSON.parse(buildOpenAIChatPassthroughRequest(target, raw, modelId, false).body) as Record; + expect(translated.thinking).toEqual({ type }); + expect(translated.reasoning_effort).toBeUndefined(); + expect(native.thinking).toBeUndefined(); + expect(native.reasoning_effort).toBe("high"); + }); + + test.each([ + ["zhipu-bigmodel", "glm-4.6"], + ["opencode-go", "mimo-v2.5"], + ] as const)("%s/%s retains legacy tool omission before toggle mapping", (providerId, modelId) => { + const entry = getProviderRegistryEntry(providerId); + if (!entry) throw new Error(`Missing registry seed ${providerId}`); + const target = { + ...providerConfigSeed(entry), apiKey: "sk-test", + omitReasoningEffortWithToolsModels: [modelId], + }; + const bodies = finalToggleBodies({ + messages: [{ role: "user", content: "think" }], reasoning_effort: "high", tools: [tool], + }, target, modelId); + for (const body of Object.values(bodies)) { + expect(body.tools).toEqual([tool]); + expect(body.thinking).toBeUndefined(); + expect(body.reasoning_effort).toBeUndefined(); + expect(body.reasoning).toBeUndefined(); + } + }); + + test("an explicit boolean disables a mapped legacy alias", () => { + const entry = getProviderRegistryEntry("zhipu-bigmodel"); + if (!entry) throw new Error("Missing registry seed zhipu-bigmodel"); + const target = { + ...providerConfigSeed(entry), + apiKey: "sk-test", + modelCapabilities: { "glm-4.6": { + supportsReasoning: true, + supportsThinkingToggle: true, + supportsReasoningEffort: true, + } }, + }; + const raw = { + model: "glm-4.6", + messages: [{ role: "user", content: "think" }], + reasoning_effort: "high", + reasoning: { enabled: false }, + }; + const parsed = parseRequest(chatCompletionsToResponsesBody(raw)); + const bodies = [ + JSON.parse(createOpenAIChatAdapter(target).buildRequest(parsed).body) as Record, + JSON.parse(buildOpenAIChatPassthroughRequest(target, raw, "glm-4.6", false).body) as Record, + ]; + for (const body of bodies) { + expect(body.thinking).toEqual({ type: "disabled" }); + expect(body.reasoning_effort).toBeUndefined(); + } + }); + + test("an undeclared wire keeps the ordinary reasoning effort", () => { + const raw = { model: "ordinary-model", messages: [{ role: "user", content: "think" }], reasoning_effort: "high" }; + const target = { ...toggleProvider, thinkingToggleModels: undefined, modelCapabilities: undefined }; + const parsed = parseRequest(chatCompletionsToResponsesBody(raw)); + const bodies = [ + JSON.parse(createOpenAIChatAdapter(target).buildRequest(parsed).body) as Record, + JSON.parse(buildOpenAIChatPassthroughRequest(target, raw, "ordinary-model", false).body) as Record, + ]; + for (const body of bodies) { + expect(body.thinking).toBeUndefined(); + expect(body.reasoning_effort).toBe("high"); + } + }); +}); diff --git a/tests/claude-integration/claude-inbound.test.ts b/tests/claude-integration/claude-inbound.test.ts index ef851cb7ca6..a8d8ce7732a 100644 --- a/tests/claude-integration/claude-inbound.test.ts +++ b/tests/claude-integration/claude-inbound.test.ts @@ -96,7 +96,7 @@ describe("claude inbound translation", () => { expect(body.stream).toBe(true); expect(body.parallel_tool_calls).toBe(false); expect(body.tool_choice).toBe("auto"); - expect(body.reasoning).toEqual({ summary: "auto", effort: "medium" }); + expect(body.reasoning).toEqual({ summary: "auto", effort: "medium", enabled: true }); const tools = body.tools as Record[]; expect(tools).toHaveLength(2); @@ -212,10 +212,13 @@ describe("claude inbound translation", () => { test("thinking variants", () => { const base = { model: "m", max_tokens: 10, messages: [{ role: "user", content: "hi" }] }; + const enabled = parseRequest(anthropicToResponsesBody({ ...base, thinking: { type: "enabled" } })); + expect(enabled.options.thinkingEnabled).toBe(true); + expect(enabled.options.reasoning).toBeUndefined(); expect((anthropicToResponsesBody({ ...base, thinking: { type: "adaptive" } }) as any).reasoning).toEqual({ summary: "auto" }); // "disabled" and omitted must NOT collapse to the same state: for a model that thinks by // default, omission means thinking is ON and shares the caller's max_tokens (#545). - expect((anthropicToResponsesBody({ ...base, thinking: { type: "disabled" } }) as any).reasoning).toEqual({ effort: "none" }); // justified: sibling assertions in this test use the same cast + expect((anthropicToResponsesBody({ ...base, thinking: { type: "disabled" } }) as any).reasoning).toEqual({ effort: "none", enabled: false }); // justified: sibling assertions in this test use the same cast expect((anthropicToResponsesBody(base) as any).reasoning).toBeUndefined(); expect(effortForThinkingBudget(1024)).toBe("low"); expect(effortForThinkingBudget(8192)).toBe("medium"); @@ -246,14 +249,14 @@ describe("claude inbound translation", () => { ...base, thinking: { type: "enabled", budget_tokens: 1024 }, output_config: { effort: "xhigh" }, - }))).toEqual({ summary: "auto", effort: "xhigh" }); + }))).toEqual({ summary: "auto", effort: "xhigh", enabled: true }); // disabled thinking suppresses effort entirely (subagent wire, claude-code#65863). // Still suppressed — "high" never reaches the wire — but now stated explicitly as the // "none" disable sentinel instead of by absence, so a default-on model is told to stop // rather than left to think anyway (#545). expect(reasoningOf(anthropicToResponsesBody({ ...base, thinking: { type: "disabled" }, output_config: { effort: "high" }, - }))).toEqual({ effort: "none" }); + }))).toEqual({ effort: "none", enabled: false }); // unknown effort strings are dropped so downstream defaults win expect(reasoningOf(anthropicToResponsesBody({ ...base, thinking: { type: "adaptive" }, output_config: { effort: "turbo" }, @@ -428,7 +431,7 @@ describe("claude inbound translation", () => { expect(body.tools).toEqual([{ type: "web_search" }]); expect(body.tool_choice).toEqual({ type: "web_search" }); - expect(body.reasoning).toEqual({ effort: "none" }); + expect(body.reasoning).toEqual({ effort: "none", enabled: false }); expect(() => responsesRequestSchema.parse(body)).not.toThrow(); expect(() => parseRequest(body)).not.toThrow(); }); diff --git a/tests/claude-integration/claude-model-info.test.ts b/tests/claude-integration/claude-model-info.test.ts index 70dbd71f0ac..991ba716c9c 100644 --- a/tests/claude-integration/claude-model-info.test.ts +++ b/tests/claude-integration/claude-model-info.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { buildAnthropicModelInfos, nativeEffectiveLadder } from "../../src/claude/model-info"; import { gatherRoutedModels, nativeEffortClamp } from "../../src/codex/catalog"; +import { MIMO_THINKING_CAPABILITIES } from "../../src/providers/mimo-thinking"; describe("anthropic-flavor ModelInfo discovery entries (devlog 130 B4b)", () => { test.each(["anthropic", "anthropic-apikey"])("%s registry image inputs reach Claude discovery aliases", async (provider) => { @@ -58,6 +59,78 @@ describe("anthropic-flavor ModelInfo discovery entries (devlog 130 B4b)", () => expect(info!.max_input_tokens).toBeNull(); }); + test("generic toggle-only models publish enabled thinking without claiming adaptive", () => { + const [info] = buildAnthropicModelInfos([], [{ + provider: "p", + id: "toggle-only", + supportsReasoning: true, + supportsThinkingToggle: true, + supportsReasoningEffort: false, + }]); + expect(info!.capabilities.thinking).toEqual({ + supported: true, + types: { adaptive: { supported: false }, enabled: { supported: true } }, + }); + }); + + test.each([ + { + name: "fixed reasoning", + capabilities: { supportsReasoning: true, supportsThinkingToggle: false, supportsReasoningEffort: false }, + ladder: ["high"], + effort: false, + thinking: { supported: true, adaptive: false, enabled: false }, + }, + { + name: "effort-only reasoning", + capabilities: { supportsReasoning: true, supportsThinkingToggle: false, supportsReasoningEffort: true }, + ladder: ["high"], + effort: true, + thinking: { supported: true, adaptive: true, enabled: false }, + }, + { + name: "combined controls", + capabilities: { supportsReasoning: true, supportsThinkingToggle: true, supportsReasoningEffort: true }, + ladder: ["high"], + effort: true, + thinking: { supported: true, adaptive: true, enabled: true }, + }, + { + name: "explicitly unsupported reasoning", + capabilities: { supportsReasoning: false, supportsThinkingToggle: true, supportsReasoningEffort: true }, + ladder: ["high"], + effort: false, + thinking: { supported: false, adaptive: false, enabled: false }, + }, + { + name: "effort declaration without a usable Claude rung", + capabilities: { supportsReasoning: true, supportsThinkingToggle: false, supportsReasoningEffort: true }, + ladder: [], + effort: false, + thinking: { supported: true, adaptive: false, enabled: false }, + }, + ])("$name preserves independent reasoning axes", ({ capabilities, ladder, effort, thinking }) => { + const [info] = buildAnthropicModelInfos([], [{ provider: "p", id: "model", reasoningEfforts: ladder, ...capabilities }]); + expect(info!.capabilities.effort.supported).toBe(effort); + expect(info!.capabilities.thinking).toEqual({ + supported: thinking.supported, + types: { adaptive: { supported: thinking.adaptive }, enabled: { supported: thinking.enabled } }, + }); + }); + + test("MiMo V2.6 publishes its documented enabled-only thinking switch", () => { + const [info] = buildAnthropicModelInfos([], [{ + provider: "xiaomi", + id: "mimo-v2.6-pro", + ...MIMO_THINKING_CAPABILITIES["mimo-v2.6-pro"], + }]); + expect(info!.capabilities.effort.supported).toBe(false); + expect(info!.capabilities.thinking).toEqual({ + supported: true, + types: { adaptive: { supported: false }, enabled: { supported: true } }, + }); + }); + test("non-anthropic rungs (ultra) are filtered out of the capability set", () => { const [info] = buildAnthropicModelInfos([], [{ provider: "p", id: "m", reasoningEfforts: ["ultra"] }]); expect(info!.capabilities.effort.supported).toBe(false); diff --git a/tests/clients/client-export-effective-metadata.test.ts b/tests/clients/client-export-effective-metadata.test.ts index 076a8a015ff..b7786681f25 100644 --- a/tests/clients/client-export-effective-metadata.test.ts +++ b/tests/clients/client-export-effective-metadata.test.ts @@ -545,6 +545,80 @@ describe("the custom projection defers to the gather's resolved values", () => { }); describe("capability booleans come from positive evidence only", () => { + test("independent capability truth survives management and both CLI projections without a fake ladder", async () => { + for (const custom of [false, true]) { + const cfg = config(custom ? { customModels: [{ id: "c1", provider: "acme", modelId: "writer" }] } : {}); + const rows = await listManagementModelRows(cfg, { + models: [{ id: "writer", provider: "acme", supportsThinkingToggle: true, + supportsReasoningEffort: false, defaultThinkingEnabled: true, supportsReasoning: true }], + }); + const row = rows.find(candidate => candidate.namespaced === "acme/writer")!; + const wire = JSON.parse(JSON.stringify(row)); + for (const exported of [toExportModel(row), ...opencodeCatalogFromProxyRows([wire], cfg), ...exportModelsFromProxyRows([wire], cfg)]) { + expect(exported).toMatchObject({ supportsThinkingToggle: true, supportsReasoning: true, + supportsReasoningEffort: false, defaultThinkingEnabled: true }); + expect(exported.reasoningEfforts ?? []).toEqual([]); + expect(exported.defaultReasoningEffort).toBeUndefined(); + expect(exported).not.toHaveProperty("reasoningControl"); + } + } + }); + + test("explicit false axes and defaults survive projection and stale efforts cannot override them", async () => { + for (const custom of [false, true]) { + const cfg = config(custom ? { customModels: [{ id: "c1", provider: "acme", modelId: "writer" }] } : {}); + const rows = await listManagementModelRows(cfg, { + models: [{ id: "writer", provider: "acme", reasoningEfforts: ["low", "high"], + defaultReasoningEffort: "high", supportsReasoning: true, supportsThinkingToggle: false, + supportsReasoningEffort: false, defaultThinkingEnabled: false }], + }); + const row = rows.find(candidate => candidate.namespaced === "acme/writer")!; + const wire = JSON.parse(JSON.stringify(row)); + for (const exported of [toExportModel(row), ...opencodeCatalogFromProxyRows([wire], cfg), ...exportModelsFromProxyRows([wire], cfg)]) { + expect(exported).toMatchObject({ supportsReasoning: true, supportsThinkingToggle: false, + supportsReasoningEffort: false, defaultThinkingEnabled: false, reasoningEfforts: [] }); + expect(exported.defaultReasoningEffort).toBeUndefined(); + } + } + }); + + test("custom-row capability survives an empty ladder and preserves explicit false", async () => { + for (const supportsReasoning of [true, false]) { + const cfg = config({ customModels: [{ id: "c1", provider: "acme", modelId: "writer", reasoningEfforts: [] }] }); + const rows = await listManagementModelRows(cfg, { + models: [gatheredCustomRow("acme", "writer", { reasoningEfforts: [], supportsReasoning })], + }); + const row = rows.find(candidate => candidate.namespaced === "acme/writer")!; + expect(row.exportMetadata?.supportsReasoning).toBe(supportsReasoning); + const wire = JSON.parse(JSON.stringify(row)); + for (const exported of [toExportModel(row), ...opencodeCatalogFromProxyRows([wire], cfg), ...exportModelsFromProxyRows([wire], cfg)]) { + expect(exported.supportsReasoning).toBe(supportsReasoning); + expect(exported.reasoningEfforts).toEqual([]); + } + } + }); + + test("rosterless custom rows inherit configured independent axes and noReasoningModels remains authoritative", async () => { + for (const suppressed of [false, true]) { + const cfg = config({ providers: { acme: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1", + modelCapabilities: { writer: { supportsReasoning: true, supportsThinkingToggle: true, + supportsReasoningEffort: false, defaultThinkingEnabled: false } }, + modelReasoningEfforts: { writer: [] }, ...(suppressed ? { noReasoningModels: ["writer"] } : {}), + } }, customModels: [{ id: "c1", provider: "acme", modelId: "writer" }] }); + const rows = await listManagementModelRows(cfg, { models: suppressed ? [gatheredCustomRow("acme", "writer", { + supportsReasoning: true, supportsThinkingToggle: true, supportsReasoningEffort: true, + defaultThinkingEnabled: true, reasoningEfforts: ["low", "high"], + })] : [] }); + const row = rows.find(candidate => candidate.namespaced === "acme/writer")!; + const wire = JSON.parse(JSON.stringify(row)); + for (const exported of [toExportModel(row), ...opencodeCatalogFromProxyRows([wire], cfg), ...exportModelsFromProxyRows([wire], cfg)]) { + expect(exported).toMatchObject({ supportsReasoning: !suppressed, supportsThinkingToggle: !suppressed, + supportsReasoningEffort: false, defaultThinkingEnabled: false, reasoningEfforts: [] }); + expect(exported.defaultReasoningEffort).toBeUndefined(); + } + } + }); + test("an off-sentinel-only ladder is not an assertion that the model can reason", async () => { const cfg = config({ customModels: [{ id: "c1", provider: "acme", modelId: "writer", reasoningEfforts: ["none"] }], diff --git a/tests/clients/client-export-reasoning-controls.test.ts b/tests/clients/client-export-reasoning-controls.test.ts index 82fa4e10ef7..d74c2e185c9 100644 --- a/tests/clients/client-export-reasoning-controls.test.ts +++ b/tests/clients/client-export-reasoning-controls.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from "bun:test"; import { buildClientConfig, type ExportModel, type KiloGeneratedConfig, type OpencodeGeneratedConfig } from "../../src/clients/config-export"; +import { deriveEntry } from "../../src/codex/catalog/derive-entry"; +import { applyProviderConfigHints } from "../../src/codex/catalog/model-hints"; const model: ExportModel = { namespaced: "mock/reasoner", provider: "mock", id: "reasoner", contextWindow: 100_000, @@ -63,6 +65,135 @@ describe("OpenCode and Kilo export real per-model controls", () => { expect(v2.settings).toEqual({ reasoningEffort: "none" }); }); + test("toggle models offer only off/on and suppress regenerated legacy strength choices", () => { + const { v1, v2, kilo } = configs({ ...model, supportsThinkingToggle: true, + supportsReasoningEffort: false, defaultThinkingEnabled: true, + reasoningEfforts: [], defaultReasoningEffort: undefined }); + expect(v2.variants).toEqual([ + { id: "none", settings: { reasoningEffort: "none" } }, + { id: "high", settings: { reasoningEffort: "high" } }, + ]); + expect(v2.settings).toEqual({ reasoningEffort: "high" }); + for (const entry of [v1, kilo]) { + expect(entry.reasoning).toBe(true); + expect(entry.options).toEqual({ reasoningEffort: "high" }); + const generated = Object.fromEntries(["minimal", "low", "medium", "high", "xhigh", "max", "ultra"] + .map(effort => [effort, { reasoningEffort: effort }])); + const merged = { ...generated, ...entry.variants }; + expect(Object.keys(merged).filter(id => !("disabled" in merged[id]!)).sort()).toEqual(["high", "none"]); + for (const effort of ["none", "high"]) { + expect({ ...entry.options, ...entry.variants![effort] }).toEqual({ reasoningEffort: effort }); + } + } + }); + + test("toggle off defaults survive and stale strength metadata cannot add choices", () => { + const { v1, v2, kilo } = configs({ ...model, supportsThinkingToggle: true, + supportsReasoningEffort: false, defaultThinkingEnabled: false, + reasoningEfforts: ["none", "low", "medium", "high", "max", "ultra"], defaultReasoningEffort: "none" }); + expect(v2.variants.map(variant => variant.id)).toEqual(["none", "high"]); + expect(v2.settings).toEqual({ reasoningEffort: "none" }); + for (const entry of [v1, kilo]) { + expect(entry.options).toEqual({ reasoningEffort: "none" }); + for (const effort of ["minimal", "low", "medium", "xhigh", "max", "ultra"]) { + expect(entry.variants![effort]).toEqual({ disabled: true }); + } + } + }); + + test("reasoning capability survives without effort or summary evidence", () => { + for (const reasoningEfforts of [undefined, []]) { + const { v1, v2, kilo } = configs({ ...model, reasoningEfforts, + supportsReasoning: true, supportsReasoningSummaries: undefined, defaultReasoningEffort: undefined }); + expect(v2.variants).toEqual([]); + for (const entry of [v1, kilo]) { + expect(entry.reasoning).toBe(true); + expect(entry.variants).toEqual(kiloSuppression); + expect(entry.options).toBeUndefined(); + } + } + }); + + test("fixed, effort-only and combined controls keep independent choices across clients and Codex", () => { + const cases = [ + { supportsThinkingToggle: false, supportsReasoningEffort: false, expected: [] }, + { supportsThinkingToggle: false, supportsReasoningEffort: true, expected: ["low", "high"] }, + { supportsThinkingToggle: true, supportsReasoningEffort: true, expected: ["none", "low", "high"] }, + { supportsThinkingToggle: true, supportsReasoningEffort: false, expected: ["none", "high"] }, + ]; + for (const { expected, ...capabilities } of cases) { + const row = { ...model, ...capabilities, reasoningEfforts: ["none", "low", "high"], + defaultReasoningEffort: "low", defaultThinkingEnabled: false }; + const { v1, v2, kilo } = configs(row); + expect(v2.variants.map(variant => variant.id)).toEqual(expected); + for (const entry of [v1, kilo]) { + expect(Object.entries(entry.variants!).filter(([, value]) => !("disabled" in value)).map(([id]) => id)) + .toEqual(expected); + expect(entry.variants!.ultra).toEqual({ disabled: true }); + } + for (const template of [null, { slug: "template", supported_reasoning_levels: [{ effort: "max" }] }]) { + const entry = deriveEntry(template, row.namespaced, "test", 1, { ...row }); + expect(entry.supported_reasoning_levels.map((level: { effort: string }) => level.effort)).toEqual(expected); + if (capabilities.supportsThinkingToggle) expect(entry.default_reasoning_level).toBe("none"); + if (expected.length === 0) expect(entry.default_reasoning_level).toBeUndefined(); + } + } + }); + + test("an empty effort declaration leaves a declared toggle available, including an unknown default", () => { + for (const reasoningEfforts of [undefined, []]) { + const row = { ...model, reasoningEfforts, supportsThinkingToggle: true, + supportsReasoningEffort: false, defaultThinkingEnabled: undefined, defaultReasoningEffort: "low" }; + const { v1, v2, kilo } = configs(row); + expect(v2.variants.map(variant => variant.id)).toEqual(["none", "high"]); + for (const entry of [v1, kilo]) expect(entry.options).toBeUndefined(); + const codex = deriveEntry(null, row.namespaced, "test", 1, row); + expect(codex.supported_reasoning_levels.map((level: { effort: string }) => level.effort)).toEqual(["none", "high"]); + expect(codex.default_reasoning_level).toBeUndefined(); + } + }); + + test("a negative toggle alone does not turn unknown effort support into false", () => { + const bare: ExportModel = { namespaced: "mock/unknown-effort", provider: "mock", id: "unknown-effort", + supportsThinkingToggle: false }; + const { v1, kilo } = configs(bare); + for (const exported of [v1, kilo]) { + expect(exported.variants).toEqual({ none: { disabled: true } }); + expect(exported.reasoning).toBeUndefined(); + } + const row = { ...model, supportsThinkingToggle: false, + reasoningEfforts: undefined, defaultReasoningEffort: undefined }; + const unknown = deriveEntry(null, model.namespaced, "test", 1, { ...row, supportsThinkingToggle: undefined }); + const entry = deriveEntry(null, model.namespaced, "test", 1, row); + expect(entry.supported_reasoning_levels).toEqual(unknown.supported_reasoning_levels.filter( + (level: { effort: string }) => level.effort !== "none")); + expect(entry.default_reasoning_level).toBe(unknown.default_reasoning_level); + }); + + test("inferred positive effort support preserves legacy Codex max/ultra synthesis", () => { + const row = applyProviderConfigHints("synthetic", { adapter: "openai-chat", baseUrl: "https://example.test/v1", + reasoningEfforts: ["low", "medium", "high"], modelDefaultReasoningEfforts: { legacy: "medium" }, + }, { provider: "synthetic", id: "legacy" }); + expect(row.supportsReasoningEffort).toBe(true); + expect(row.supportsThinkingToggle).toBeUndefined(); + for (const template of [null, { slug: "template", supported_reasoning_levels: [{ effort: "xhigh" }] }]) { + const entry = deriveEntry(template, "synthetic/legacy", "test", 1, row); + expect(entry.supported_reasoning_levels.map((level: { effort: string }) => level.effort)) + .toEqual(["low", "medium", "high", "max", "ultra"]); + expect(entry.default_reasoning_level).toBe("medium"); + const suppressedMax = deriveEntry(template, "synthetic/legacy", "test", 1, { ...row, suppressSyntheticMax: true }); + expect(suppressedMax.supported_reasoning_levels.map((level: { effort: string }) => level.effort)) + .toEqual(["low", "medium", "high", "ultra"]); + const exact = deriveEntry(template, "synthetic/legacy", "test", 1, { ...row, supportsThinkingToggle: false }); + expect(exact.supported_reasoning_levels.map((level: { effort: string }) => level.effort)) + .toEqual(["low", "medium", "high"]); + const fixed = deriveEntry(template, "synthetic/legacy", "test", 1, { ...row, supportsReasoningEffort: false }); + expect(fixed.supported_reasoning_levels).toEqual([]); + const disabled = deriveEntry(template, "synthetic/legacy", "test", 1, { ...row, supportsReasoning: false }); + expect(disabled.supported_reasoning_levels).toEqual([]); + } + }); + test("an empty ladder suppresses inferred choices without falsely declaring inability to reason", () => { const { v1, v2, kilo } = configs({ ...model, reasoningEfforts: [] }); expect(v2.variants).toEqual([]); diff --git a/tests/clients/client-export-reasoning-metadata.test.ts b/tests/clients/client-export-reasoning-metadata.test.ts index 2ef80e1240f..763249d1155 100644 --- a/tests/clients/client-export-reasoning-metadata.test.ts +++ b/tests/clients/client-export-reasoning-metadata.test.ts @@ -1,11 +1,25 @@ import { describe, expect, test } from "bun:test"; -import { exportDefaultReasoningEffort, exportReasoningEfforts } from "../../src/clients/config-export/reasoning-metadata"; +import { exportDefaultReasoningEffort, exportReasoningEfforts, resolveExportReasoningAxes } from "../../src/clients/config-export/reasoning-metadata"; import { inputBudgetFor } from "../../src/clients/config-export/model-metadata"; import type { OpencodeCatalogModel } from "../../src/clients/config-export"; const model: OpencodeCatalogModel = { namespaced: "mock/model" }; describe("client export reasoning metadata", () => { + test("row projections share effective axes without reviving a picker default", () => { + const row = { reasoningEfforts: ["low", "high"], defaultReasoningEffort: "low", + supportsThinkingToggle: true, supportsReasoningEffort: true, defaultThinkingEnabled: true }; + expect(resolveExportReasoningAxes(undefined, row)).toMatchObject(row); + expect(resolveExportReasoningAxes({}, row).defaultReasoningEffort).toBeUndefined(); + for (const disabled of [{ supportsReasoning: false }, { supportsReasoningEffort: false }]) { + expect(resolveExportReasoningAxes(disabled, row)).toMatchObject({ + reasoningEfforts: [], defaultReasoningEffort: undefined, + }); + expect(resolveExportReasoningAxes(disabled, {}).reasoningEfforts).toBeUndefined(); + } + expect(resolveExportReasoningAxes({ supportsThinkingToggle: false, defaultThinkingEnabled: false }, row)) + .toMatchObject({ supportsThinkingToggle: false, defaultThinkingEnabled: false, reasoningEfforts: ["low", "high"] }); + }); test("unknown ladders remain unknown and explicit empty ladders remain empty", () => { expect(exportReasoningEfforts(model)).toBeUndefined(); expect(exportReasoningEfforts({ ...model, reasoningEfforts: [] })).toEqual([]); @@ -14,6 +28,19 @@ describe("client export reasoning metadata", () => { expect(exportReasoningEfforts({ ...model, reasoningEfforts: ["high", "none", "turbo", "low", "high", "minimal"] })) .toEqual(["none", "minimal", "low", "high"]); }); + test("client effort-enum encoding represents a toggle independently of declared strengths", () => { + const toggle = { ...model, supportsThinkingToggle: true, supportsReasoningEffort: false, defaultThinkingEnabled: true }; + expect(exportReasoningEfforts(toggle)).toEqual(["none", "high"]); + expect(exportReasoningEfforts({ ...toggle, reasoningEfforts: [] })).toEqual(["none", "high"]); + expect(exportReasoningEfforts({ ...toggle, reasoningEfforts: ["high", "low", "none", "medium", "max", "ultra"] })) + .toEqual(["none", "high"]); + expect(exportDefaultReasoningEffort({ ...toggle, reasoningEfforts: ["none", "high"], defaultReasoningEffort: "high" })) + .toBe("high"); + expect(exportDefaultReasoningEffort({ ...toggle, reasoningEfforts: ["none", "high", "medium"], defaultReasoningEffort: "medium" })) + .toBe("high"); + expect(exportDefaultReasoningEffort({ ...toggle, defaultThinkingEnabled: false })).toBe("none"); + expect(exportDefaultReasoningEffort({ ...toggle, defaultThinkingEnabled: undefined })).toBeUndefined(); + }); test("does not synthesize a default or revive an explicitly cleared ladder", () => { expect(exportDefaultReasoningEffort({ ...model, reasoningEfforts: ["low", "high"] })).toBeUndefined(); expect(exportDefaultReasoningEffort({ ...model, defaultReasoningEffort: "high", reasoningEfforts: [] })).toBeUndefined(); diff --git a/tests/codex-integration/effort-policy.test.ts b/tests/codex-integration/effort-policy.test.ts index 0d3ac76018d..248cc692055 100644 --- a/tests/codex-integration/effort-policy.test.ts +++ b/tests/codex-integration/effort-policy.test.ts @@ -15,6 +15,24 @@ import { mapReasoningEffort } from "../../src/reasoning-effort"; import { nativeEffortClamp } from "../../src/codex/catalog"; import type { OcxConfig, OcxParsedRequest } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { prepareEffortNormalization } from "../../src/server/effort-policy"; +import { normalizeThinkingOptions } from "../../src/clients/reasoning-controls"; + +test("a toggle-only client's choice survives strength policy and is restored on fallback", () => { + const provider = { adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1", modelCapabilities: { + fixed: { supportsReasoning: true, supportsThinkingToggle: true, supportsReasoningEffort: false, defaultThinkingEnabled: true }, + } }; + const parsed = makeParsed("none"); + prepareEffortNormalization(parsed, { providerName: "fixture", modelId: "fixed" }); + normalizeThinkingOptions(provider, "fixed", parsed.options); + expect(parsed.options.thinkingEnabled).toBe(false); + expect(parsed.options.reasoning).toBeUndefined(); + applyEffortCap(parsed, new Headers(), makeConfig({ effortCap: "low" }), []); + expect(parsed.options.thinkingEnabled).toBe(false); + prepareEffortNormalization(parsed, { providerName: "fixture", modelId: "adjustable" }); + expect(parsed.options.thinkingEnabled).toBeUndefined(); + expect(parsed.options.reasoning).toBe("none"); +}); const savedHome = process.env.OPENCODEX_HOME; const savedCodexHome = process.env.CODEX_HOME; diff --git a/tests/config/config-load-degrade.test.ts b/tests/config/config-load-degrade.test.ts index 5e7d4dd2f99..2cad3a10bbb 100644 --- a/tests/config/config-load-degrade.test.ts +++ b/tests/config/config-load-degrade.test.ts @@ -12,6 +12,7 @@ import { validateConfigCandidate, } from "../../src/config"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { mergeModelCapabilities, modelCapabilitiesConfigError } from "../../src/config/provider-validation"; let home = ""; let previousHome: string | undefined; @@ -222,7 +223,7 @@ test("model capability writes stay strict while load preserves independent restr apiKey: "fixture-key", modelCapabilities: { ModelA: { inputModalities: ["text"] }, modela: { contextTier: "long_context" }, - broken: { inputModalities: "image", contextTier: "typo" }, + broken: { inputModalities: "image", contextTier: "typo", supportsReasoning: true, supportsThinkingToggle: "true", supportsReasoningEffort: false, defaultThinkingEnabled: false }, }, } } }; expect(validateConfigCandidate(raw).ok).toBe(false); @@ -230,7 +231,7 @@ test("model capability writes stay strict while load preserves independent restr const loaded = loadConfig(); expect(loaded.providers.xai.modelCapabilities).toEqual({ ModelA: { inputModalities: ["text"] }, modela: { contextTier: "long_context" }, - broken: { inputModalities: ["text"] }, + broken: { inputModalities: ["text"], supportsReasoning: true, supportsReasoningEffort: false, defaultThinkingEnabled: false }, }); expect(loaded.providers.xai.apiKey).toBe("fixture-key"); expect(validateConfigCandidate(loaded).ok).toBe(true); @@ -239,7 +240,7 @@ test("model capability writes stay strict while load preserves independent restr test("model capabilities round-trip all explicit axes without expanding inference", () => { const raw = { ...candidate(undefined), providers: { xai: { ...candidate(undefined).providers.xai, - modelCapabilities: { ModelA: { inputModalities: ["text", "image"], contextTier: "long_context", video: { processing: "agentic" } } }, + modelCapabilities: { ModelA: { inputModalities: ["text", "image"], contextTier: "long_context", video: { processing: "agentic" }, supportsReasoning: true, supportsThinkingToggle: true, supportsReasoningEffort: false, defaultThinkingEnabled: false } }, } } }; const validated = validateConfigCandidate(raw); expect(validated.ok).toBe(true); @@ -249,6 +250,21 @@ test("model capabilities round-trip all explicit axes without expanding inferenc expect(loadConfig().providers.xai.modelContextWindows).toBeUndefined(); }); +test("reasoning capability patches preserve false and independently delete or validate axes", () => { + const original = { ModelA: { supportsReasoning: true, supportsThinkingToggle: true, supportsReasoningEffort: false, defaultThinkingEnabled: true } }; + const patch = { ModelA: { supportsThinkingToggle: false, defaultThinkingEnabled: null } }; + expect(modelCapabilitiesConfigError(patch, true)).toBeNull(); + expect(mergeModelCapabilities(original, patch)).toEqual({ ModelA: { + supportsReasoning: true, supportsThinkingToggle: false, supportsReasoningEffort: false, + } }); + expect(original.ModelA.defaultThinkingEnabled).toBe(true); + for (const field of ["supportsReasoning", "supportsThinkingToggle", "supportsReasoningEffort", "defaultThinkingEnabled"]) { + expect(modelCapabilitiesConfigError({ ModelA: { [field]: "false" } })).not.toBeNull(); + expect(modelCapabilitiesConfigError({ ModelA: { [field]: false } })).toBeNull(); + expect(modelCapabilitiesConfigError({ ModelA: { [field]: null } })).not.toBeNull(); + } +}); + test.each([ { enabled: "true" }, { enabled: true, port: 70000 }, diff --git a/tests/providers/cursor/cursor-local-models-schema.test.ts b/tests/providers/cursor/cursor-local-models-schema.test.ts index 2d8e0fb8c75..06a397face1 100644 --- a/tests/providers/cursor/cursor-local-models-schema.test.ts +++ b/tests/providers/cursor/cursor-local-models-schema.test.ts @@ -18,6 +18,18 @@ import type { OcxConfig } from "../../../src/types"; import { SERVER_BUDGET_MS } from "../../helpers/test-budget"; import { removeTreeWithRetry } from "../../helpers/remove-tree"; +test("public discovery separates reasoning, toggle and real strength controls", () => { + const fixed = modelCapabilityFields({ supportsReasoning: true, supportsThinkingToggle: false, supportsReasoningEffort: false }); + expect(fixed.capabilities).toMatchObject({ supports_reasoning: true, supports_thinking_toggle: false, supports_reasoning_effort: false }); + expect(fixed.capabilities.reasoning_effort).toBeUndefined(); + const toggle = modelCapabilityFields({ supportsReasoning: true, supportsThinkingToggle: true, supportsReasoningEffort: false, defaultThinkingEnabled: false, reasoningEfforts: ["none", "high"] }); + expect(toggle.capabilities).toMatchObject({ supports_reasoning: true, supports_thinking_toggle: true, supports_reasoning_effort: false, default_thinking_enabled: false }); + expect(toggle.capabilities.reasoning_effort).toBeUndefined(); + const both = modelCapabilityFields({ supportsReasoning: true, supportsThinkingToggle: true, supportsReasoningEffort: true, reasoningEfforts: ["low", "high"] }); + expect(both.capabilities.reasoning_effort).toEqual(["low", "high"]); + expect(modelCapabilityFields({}).capabilities.supports_thinking_toggle).toBeUndefined(); +}); + // Cursor's local-agent runtime ("Private Inference" build) only enables its reasoning-effort // control when a GET /v1/models row carries api_types (+ optional capabilities). These cases // start a real server and read the raw OpenAI-shape list, like the Grok discovery tests. @@ -80,12 +92,46 @@ describe("modelCapabilityFields", () => { output_modalities: ["text"], supports_tool_use: true, supports_streaming: true, - supports_reasoning: false, }); expect("context_length" in fields.capabilities).toBe(false); expect("input_modalities" in fields.capabilities).toBe(false); expect("supports_vision" in fields.capabilities).toBe(false); expect("reasoning_effort" in fields.capabilities).toBe(false); + expect("supports_reasoning" in fields.capabilities).toBe(false); + }); + + test.each([{ supportsThinkingToggle: true }, { supportsReasoningEffort: true }])("positive reasoning axis infers support: %j", input => { + const fields = modelCapabilityFields(input); + expect(fields.capabilities.supports_reasoning).toBe(true); + expect(fields.capabilities.reasoning_effort).toBeUndefined(); + }); + + test("negative axes alone leave reasoning unknown and explicit false suppresses efforts", () => { + expect(modelCapabilityFields({ supportsThinkingToggle: false, supportsReasoningEffort: false }) + .capabilities.supports_reasoning).toBeUndefined(); + const fields = modelCapabilityFields({ supportsReasoning: false, supportsThinkingToggle: true, + supportsReasoningEffort: true, reasoningEfforts: ["low", "high"] }); + expect(fields.capabilities.supports_reasoning).toBe(false); + expect(fields.capabilities.reasoning_effort).toBeUndefined(); + }); + + test("legacy empty ladders retain the negative discovery signal without overriding declared axes", () => { + const legacy = modelCapabilityFields({ reasoningEfforts: [] }).capabilities; + expect(legacy.supports_reasoning).toBe(false); + expect(legacy).not.toHaveProperty("reasoning_effort"); + expect(legacy).not.toHaveProperty("supports_thinking_toggle"); + expect(legacy).not.toHaveProperty("supports_reasoning_effort"); + for (const axes of [{ supportsReasoning: true }, { supportsThinkingToggle: true }, + { supportsReasoning: true, supportsThinkingToggle: true, supportsReasoningEffort: false }]) { + const explicit = modelCapabilityFields({ reasoningEfforts: [], ...axes }).capabilities; + expect(explicit.supports_reasoning).toBe(true); + expect(explicit).not.toHaveProperty("reasoning_effort"); + } + for (const axes of [{ supportsThinkingToggle: false }, { supportsReasoningEffort: false }]) { + expect(modelCapabilityFields({ reasoningEfforts: [], ...axes }).capabilities.supports_reasoning) + .toBeUndefined(); + } + expect(modelCapabilityFields({}).capabilities).not.toHaveProperty("supports_reasoning"); }); test("non-positive context and text-only modalities are reported honestly", () => { @@ -121,7 +167,7 @@ describe("modelCapabilityFields", () => { expect("max_output_tokens" in modelCapabilityFields({ maxOutputTokens: Number.MAX_SAFE_INTEGER + 2 }).capabilities) .toBe(false); expect(modelCapabilityFields({ maxOutputTokens: 1.9 }).capabilities.supports_reasoning) - .toBe(false); + .toBeUndefined(); }); test("mirrors top-level context_window and max_output_tokens for external and legacy client discovery", () => { @@ -191,6 +237,7 @@ describe("raw /v1/models list advertises Cursor local-agent capabilities", () => supports_tool_use: true, supports_streaming: true, supports_reasoning: true, + supports_reasoning_effort: true, supports_vision: true, reasoning_effort: ["low", "high", "max"], }); diff --git a/tests/providers/mimo-v26-catalog.test.ts b/tests/providers/mimo-v26-catalog.test.ts index 65f924eb5f4..6da113987bc 100644 --- a/tests/providers/mimo-v26-catalog.test.ts +++ b/tests/providers/mimo-v26-catalog.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { applyProviderConfigHints } from "../../src/codex/catalog"; +import { applyProviderConfigHints, gatherRoutedModels } from "../../src/codex/catalog"; import { generatedModelMetadata } from "../../src/codex/catalog/parsing"; import { getModelMetadata, resolveMetadataProvider } from "../../src/generated/model-metadata"; import { enrichProviderFromRegistry, providerConfigSeed } from "../../src/providers/derive"; @@ -13,9 +13,308 @@ import { import { routeModel } from "../../src/router"; import type { OcxConfig, OcxProviderConfig } from "../../src/types"; import { resolveMatchedPrice } from "../../src/usage/cost"; +import { modelCapabilityFields } from "../../src/server/models-capabilities"; +import { deriveEntry } from "../../src/codex/catalog/derive-entry"; +import { MIMO_THINKING_MODELS } from "../../src/providers/mimo-thinking"; +import { resolveReasoningCapabilities } from "../../src/providers/reasoning-capabilities"; +import { catalogHintsFromModelsApiItem, copyReasoningHintSource, withVisionSidecarInput } from "../../src/codex/catalog/model-hints"; +import { augmentRoutedModelsWithMetadata } from "../../src/codex/catalog/routed-gather"; +import { mergeConfiguredModelsIntoLiveCatalog } from "../../src/codex/catalog/model-visibility"; +import { clearModelCache, getFreshCached, setCached } from "../../src/codex/model-cache"; +import { fetchProviderModels } from "../../src/codex/catalog/provider-models"; // Xiaomi's published V2.6 prices (USD per 1M tokens), as carried by models.dev on 2026-09-23. const PRO = { input: 0.435, output: 0.87, cacheRead: 0.0036, cacheWrite: 0 }; + +describe("independent reasoning capabilities", () => { + test("retained warm-cache rows withdraw removed configuration-only reasoning axes", async () => { + const name = "retained-reasoning-provenance"; + const modelId = "retained-model"; + let requests = 0; + const provider: OcxProviderConfig = { + adapter: "openai-chat", baseUrl: "https://synthetic.example.test/v1", authMode: "key", + apiKey: "test-key", models: [modelId], retainModels: [modelId], + fetch: (async () => { requests++; return Response.json({ data: [] }); }) as typeof fetch, + }; + try { + const first = await fetchProviderModels(name, { ...provider, modelCapabilities: { + [modelId]: { supportsThinkingToggle: true, supportsReasoningEffort: false }, + } }, 60_000); + expect(first.find(row => row.id === modelId)).toMatchObject({ + supportsThinkingToggle: true, supportsReasoningEffort: false, + }); + const warm = await fetchProviderModels(name, provider, 60_000); + const row = warm.find(row => row.id === modelId)!; + expect(row).toBeDefined(); + expect(row.supportsThinkingToggle).toBeUndefined(); + expect(row.supportsReasoningEffort).toBeUndefined(); + expect(requests).toBe(1); + } finally { + clearModelCache(name); + } + }); + const base = { adapter: "openai-chat", baseUrl: "https://synthetic.example.test/v1" }; + const id = "synthetic-model"; + test.each([ + { supportsReasoning: true, supportsThinkingToggle: false, supportsReasoningEffort: false, defaultThinkingEnabled: true }, + { supportsReasoning: true, supportsThinkingToggle: true, supportsReasoningEffort: false, defaultThinkingEnabled: false }, + { supportsReasoning: true, supportsThinkingToggle: false, supportsReasoningEffort: true }, + { supportsReasoning: true, supportsThinkingToggle: true, supportsReasoningEffort: true }, + {}, + ])("synthetic fixed/toggle/effort/both/unknown contract %j", capabilities => { + const provider = { ...base, modelCapabilities: { [id]: capabilities } }; + expect(resolveReasoningCapabilities(provider, id)).toEqual(capabilities); + const row = applyProviderConfigHints("synthetic", provider, { provider: "synthetic", id }); + for (const field of ["supportsReasoning", "supportsThinkingToggle", "supportsReasoningEffort", "defaultThinkingEnabled"] as const) { + expect(row[field]).toBe(capabilities[field]); + } + }); + test("toggle and effort declarations remain independent, including empty and none-only lists", () => { + const toggle = { ...base, thinkingToggleModels: [id], modelReasoningEfforts: { [id]: [] }, modelCapabilities: { [id]: { supportsThinkingToggle: true } } }; + expect(resolveReasoningCapabilities(toggle, id)).toEqual({ supportsReasoning: true, supportsThinkingToggle: true }); + expect(resolveReasoningCapabilities({ ...base, modelReasoningEfforts: { [id]: ["none"] } }, id)) + .toEqual({}); + expect(resolveReasoningCapabilities({ ...base, thinkingToggleModels: [id] }, id)).toEqual({}); + expect(resolveReasoningCapabilities({ ...base, modelCapabilities: { [id]: { supportsThinkingToggle: false } }, thinkingToggleModels: [id] }, id)) + .toEqual({ supportsThinkingToggle: false }); + const suppressed = resolveReasoningCapabilities({ ...toggle, noReasoningModels: [id] }, id); + expect(suppressed).toEqual({ supportsReasoning: false, supportsThinkingToggle: false, supportsReasoningEffort: false, defaultThinkingEnabled: false }); + }); + test.each(["zhipu-bigmodel", "opencode-go", "volcengine", "minimax"])("%s toggle wire aliases do not assert genuine strengths", preset => { + const entry = PROVIDER_REGISTRY.find(row => row.id === preset)!; + const id = entry.thinkingToggleModels![0]!; + const provider = providerConfigSeed(entry); + const resolved = resolveReasoningCapabilities(provider, id); + expect(resolved.supportsThinkingToggle).toBeUndefined(); + expect(resolved.supportsReasoning).toBe(true); + expect(resolved.supportsReasoningEffort).toBeUndefined(); + const row = applyProviderConfigHints(preset, provider, { provider: preset, id }); + expect(row.supportsReasoningEffort).toBeUndefined(); + expect(row.reasoningEfforts?.length).toBeGreaterThan(0); + const explicit = { ...provider, modelCapabilities: { [id]: { supportsThinkingToggle: true, supportsReasoningEffort: true } } }; + expect(resolveReasoningCapabilities(explicit, id).supportsThinkingToggle).toBe(true); + expect(resolveReasoningCapabilities(explicit, id).supportsReasoningEffort).toBe(true); + expect(applyProviderConfigHints(preset, explicit, { provider: preset, id }).reasoningEfforts).toEqual(row.reasoningEfforts); + }); + test("mapped strength inference follows folded overrides and preserves empty overrides", () => { + const provider = { ...base, thinkingToggleModels: [id], modelReasoningEfforts: { [id]: ["low", "high"] }, + modelReasoningEffortMap: { "SYNTHETIC-MODEL": { low: "disabled", high: "enabled" } } }; + expect(resolveReasoningCapabilities(provider, id).supportsReasoningEffort).toBeUndefined(); + expect(resolveReasoningCapabilities({ ...provider, modelReasoningEffortMap: { [id]: {} } }, id).supportsReasoningEffort).toBe(true); + expect(resolveReasoningCapabilities({ ...base, modelReasoningEfforts: { [id]: ["none"] }, reasoningEffortMap: { none: "high" } }, id)).toEqual({}); + const registered = PROVIDER_REGISTRY.find(row => row.id === "zhipu-bigmodel-responses")!; + expect(resolveReasoningCapabilities({ adapter: registered.adapter, baseUrl: registered.baseUrl, + modelReasoningEfforts: { "GLM-5.3": [] } }, "glm-5.3").supportsReasoningEffort).toBeUndefined(); + }); + test("toggle-only does not inherit an old provider-wide ladder or default", () => { + const provider = { ...base, reasoningEfforts: ["low", "medium", "high"], modelDefaultReasoningEfforts: { [id]: "high" }, + modelCapabilities: { [id]: { supportsReasoning: true, supportsThinkingToggle: true, supportsReasoningEffort: false } } }; + const row = applyProviderConfigHints("synthetic", provider, { provider: "synthetic", id, reasoningEfforts: ["low", "high"], defaultReasoningEffort: "high" }); + expect(row.reasoningEfforts).toEqual([]); + expect(row.defaultReasoningEffort).toBeUndefined(); + expect(row.supportsThinkingToggle).toBe(true); + const enabled = applyProviderConfigHints("synthetic", { ...provider, modelCapabilities: { [id]: { supportsReasoningEffort: true } } }, row); + expect(enabled.reasoningEfforts).toEqual(["low", "medium", "high"]); + }); + test("cached config projections rederive flags and retain original discovery metadata", () => { + const discovered = { provider: "synthetic", id, supportsReasoning: true, supportsThinkingToggle: true, supportsReasoningEffort: false, defaultThinkingEnabled: false }; + const cached = applyProviderConfigHints("synthetic", { ...base, modelCapabilities: { [id]: { supportsThinkingToggle: false } } }, discovered); + expect(cached.supportsThinkingToggle).toBe(false); + expect(applyProviderConfigHints("synthetic", { ...base, modelCapabilities: { [id]: { supportsThinkingToggle: false } } }, cached)).toEqual(cached); + const restored = applyProviderConfigHints("synthetic", base, cached); + expect(restored).toMatchObject(discovered); + const configured = applyProviderConfigHints("synthetic", { ...base, modelCapabilities: { [id]: { supportsThinkingToggle: true } } }, { provider: "synthetic", id }); + expect(applyProviderConfigHints("synthetic", base, configured).supportsThinkingToggle).toBeUndefined(); + }); + test("dated aliases inherit discovery evidence, not the dated row's config projection", () => { + const datedId = `${id}-20261001`; + const discovered = { provider: "synthetic", id: datedId, supportsReasoning: true, + supportsThinkingToggle: true, supportsReasoningEffort: true, defaultThinkingEnabled: true, + reasoningEfforts: ["low", "high"], defaultReasoningEffort: "low" }; + const provider = { ...base, modelCapabilities: { [datedId]: { supportsReasoning: false } } }; + const dated = applyProviderConfigHints("synthetic", provider, discovered); + expect(dated.reasoningEfforts).toEqual([]); + const merged = mergeConfiguredModelsIntoLiveCatalog({ name: "synthetic", provider, models: [dated], + configured: [{ provider: "synthetic", id }] }); + const alias = merged.models.find(row => row.id === id)!; + expect(alias).toMatchObject({ ...discovered, id }); + expect(alias).toEqual(applyProviderConfigHints("synthetic", provider, { ...discovered, id })); + expect(applyProviderConfigHints("synthetic", provider, copyReasoningHintSource(alias, { ...alias }))).toEqual(alias); + expect(dated.supportsReasoning).toBe(false); + }); + test("routed spread clones restore discovery and withdraw removed config-only hints", () => { + const discovered = { provider: "synthetic", id, supportsReasoning: true, supportsThinkingToggle: true, + supportsReasoningEffort: true, reasoningEfforts: ["low", "high"], defaultReasoningEffort: "low" }; + const disabled = applyProviderConfigHints("synthetic", { ...base, noReasoningModels: [id] }, discovered); + const routed = copyReasoningHintSource(disabled, { ...disabled, providerAlias: "routed", contextWindow: 100_000 }); + const restoredDiscovery = applyProviderConfigHints("synthetic", base, routed); + expect(restoredDiscovery).toMatchObject(discovered); + expect(applyProviderConfigHints("synthetic", base, copyReasoningHintSource(restoredDiscovery, { ...restoredDiscovery }))).toEqual(restoredDiscovery); + const configured = applyProviderConfigHints("synthetic", { ...base, + modelCapabilities: { [id]: { supportsThinkingToggle: true } }, + modelReasoningEfforts: { [id]: ["high"] }, modelDefaultReasoningEfforts: { [id]: "high" }, + }, { provider: "synthetic", id }); + const restored = applyProviderConfigHints("synthetic", base, copyReasoningHintSource(configured, { ...configured, providerAlias: "routed" })); + for (const field of ["supportsReasoning", "supportsThinkingToggle", "supportsReasoningEffort", "reasoningEfforts", "defaultReasoningEffort"] as const) { + expect(restored[field]).toBeUndefined(); + } + expect(JSON.parse(JSON.stringify(routed))).toEqual({ ...JSON.parse(JSON.stringify(disabled)), + providerAlias: "routed", contextWindow: 100_000 }); + expect(Object.getOwnPropertySymbols(routed)).toEqual([]); + expect(routed).toEqual(JSON.parse(JSON.stringify(routed))); + expect(Object.getOwnPropertySymbols(JSON.parse(JSON.stringify(routed)))).toEqual([]); + expect(JSON.stringify(routed)).not.toContain("reasoningHintSource"); + }); + test("cache reads and spread clones retain discovery through repeated config changes", () => { + const provider = "reasoning-provenance-test"; + const discovered = { provider, id, supportsReasoning: true, supportsThinkingToggle: true, + supportsReasoningEffort: true, reasoningEfforts: ["low", "high"], defaultReasoningEffort: "low" }; + try { + const disabled = applyProviderConfigHints(provider, { ...base, noReasoningModels: [id] }, discovered); + setCached(provider, [disabled]); + const [cached] = getFreshCached(provider, 60_000)!; + expect(cached).toEqual(disabled); + const restored = applyProviderConfigHints(provider, base, copyReasoningHintSource(cached!, { ...cached! })); + expect(restored).toEqual(applyProviderConfigHints(provider, base, discovered)); + expect(applyProviderConfigHints(provider, base, restored)).toEqual(restored); + expect(cached!.supportsReasoning).toBe(false); + expect(discovered.reasoningEfforts).toEqual(["low", "high"]); + } finally { + clearModelCache(provider); + } + }); + test("metadata window fills retain discovery through repeated config changes", () => { + const provider = "opencode-go"; + const modelId = "glm-5.2"; + const discovered = { provider, id: modelId, supportsThinkingToggle: true, reasoningEfforts: ["low", "high"] }; + const config = { ...base, modelCapabilities: { [modelId]: { supportsThinkingToggle: false } } }; + const projected = applyProviderConfigHints(provider, config, discovered); + expect(projected.contextWindow).toBeUndefined(); + const augmented = augmentRoutedModelsWithMetadata([projected], [provider], { [provider]: config }); + const seeded = augmented.find(row => row.provider === provider && row.id === modelId)!; + expect(seeded.contextWindow).toBeGreaterThan(0); + expect(seeded.supportsThinkingToggle).toBe(false); + expect(applyProviderConfigHints(provider, base, seeded)).toMatchObject(discovered); + expect(applyProviderConfigHints(provider, config, seeded)).toEqual(seeded); + }); + test("sidecar attachment clones preserve discovery without adding object metadata", () => { + const discovered = { provider: "synthetic", id, supportsThinkingToggle: true }; + const projected = applyProviderConfigHints("synthetic", { ...base, + modelCapabilities: { [id]: { supportsThinkingToggle: false } } }, discovered); + const attached = withVisionSidecarInput(projected, { ...base, noVisionModels: [id] }); + expect(attached.inputModalities).toEqual(["text", "image"]); + expect(Object.getOwnPropertySymbols(attached)).toEqual([]); + expect(attached).toEqual(JSON.parse(JSON.stringify(attached))); + expect(applyProviderConfigHints("synthetic", base, attached).supportsThinkingToggle).toBe(true); + }); + test("unknown reasoning stays absent through a public discovery roundtrip", () => { + const fields = modelCapabilityFields({}); + expect(fields.capabilities).not.toHaveProperty("supports_reasoning"); + const hints = catalogHintsFromModelsApiItem("chain", { id, ...JSON.parse(JSON.stringify(fields)) }); + for (const field of ["supportsReasoning", "supportsThinkingToggle", "supportsReasoningEffort", "defaultThinkingEnabled", "reasoningEfforts"] as const) { + expect(hints).not.toHaveProperty(field); + } + }); + test.each([ + { efforts: [], cap: undefined, sidecar: false }, + { efforts: ["high"], cap: undefined, sidecar: false }, + { efforts: [], cap: 350_000, sidecar: true }, + { efforts: ["high"], cap: 350_000, sidecar: true }, + ])("custom reasoning overrides remain authoritative for combo members: %j", async ({ efforts, cap, sidecar }) => { + const provider = "custom-reasoning-provenance"; + const config: OcxConfig = { + port: 10100, defaultProvider: provider, + providers: { [provider]: { ...base, authMode: "key", apiKey: "test-key", liveModels: true, models: [id], + modelCapabilities: { [id]: { supportsThinkingToggle: true } }, ...(sidecar ? { noVisionModels: [id] } : {}), + fetch: (() => { throw new Error("cached discovery must not fetch"); }) as typeof fetch, + } }, + ...(cap !== undefined ? { providerContextCaps: { [provider]: cap } } : {}), + customModels: [{ id: "custom", provider, modelId: id, reasoningEfforts: efforts, + addedAt: "2026-10-23T00:00:00.000Z" }], + combos: { solo: { targets: [{ provider, model: id }] } }, + }; + try { + setCached(provider, [{ provider, id, reasoningEfforts: ["low", "high"] }]); + const discovered = await gatherRoutedModels({ ...config, customModels: [], combos: undefined }); + expect(discovered.find(row => row.provider === provider && row.id === id)?.reasoningEfforts).toEqual(["low", "high"]); + const models = await gatherRoutedModels(config); + const custom = models.find(row => row.provider === provider && row.id === id); + const combo = models.find(row => row.provider === "combo" && row.id === "solo"); + expect(custom).toBeDefined(); + expect(combo).toBeDefined(); + expect(custom!.reasoningEfforts).toEqual(efforts); + expect(combo!.reasoningEfforts).toEqual(efforts); + } finally { + clearModelCache(provider); + } + }); + test("chained public model flags preserve false and unknown independently of efforts", () => { + const hints = catalogHintsFromModelsApiItem("chain", { id, capabilities: { + supports_reasoning: true, supports_thinking_toggle: true, supports_reasoning_effort: false, + default_thinking_enabled: false, reasoning_effort: ["low", "high"], + } }); + expect(hints).toMatchObject({ supportsReasoning: true, supportsThinkingToggle: true, supportsReasoningEffort: false, defaultThinkingEnabled: false }); + expect(hints.reasoningEfforts).toEqual([]); + const chained = applyProviderConfigHints("chain", { ...base, reasoningEfforts: ["low", "medium", "high"] }, { provider: "chain", id, ...hints }); + expect(chained.supportsReasoningEffort).toBe(false); + expect(chained.reasoningEfforts).toEqual([]); + const unknown = catalogHintsFromModelsApiItem("chain", { id, capabilities: {} }); + expect(unknown.supportsReasoning).toBeUndefined(); + expect(unknown.supportsThinkingToggle).toBeUndefined(); + const explicitFalse = catalogHintsFromModelsApiItem("chain", { id, capabilities: { supports_reasoning: false, supports_thinking_toggle: false } }); + expect(explicitFalse).toMatchObject({ supportsReasoning: false, supportsThinkingToggle: false }); + expect(explicitFalse.supportsReasoningEffort).toBeUndefined(); + }); + test.each(["supports_reasoning", "supports_reasoning_effort"])("chained %s=false clears contradictory efforts", field => { + const hints = catalogHintsFromModelsApiItem("chain", { id, capabilities: { + [field]: false, reasoning_effort: ["low", "high"], + } }); + expect(hints.reasoningEfforts).toEqual([]); + }); +}); + +describe("MiMo V2.6 thinking switch", () => { + test.each(["xiaomi", "xiaomi-mimo", "my-mimo"])("public Chat under %s exposes capabilities without synthetic strengths", name => { + const provider: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://api.xiaomimimo.com/v1", authMode: "key" }; + enrichProviderFromRegistry(name, provider); + for (const id of MIMO_THINKING_MODELS) { + const row = applyProviderConfigHints(name, provider, { provider: name, id }); + expect(row).toMatchObject({ supportsReasoning: true, supportsThinkingToggle: true, supportsReasoningEffort: false, defaultThinkingEnabled: true, reasoningEfforts: [] }); + expect(row.defaultReasoningEffort).toBeUndefined(); + expect(modelCapabilityFields(row).capabilities).toMatchObject({ supports_reasoning: true, supports_thinking_toggle: true, supports_reasoning_effort: false, default_thinking_enabled: true }); + expect(modelCapabilityFields(row).capabilities.reasoning_effort).toBeUndefined(); + const catalog = deriveEntry(null, `${name}/${id}`, "MiMo", 1, row); + expect(catalog.supported_reasoning_levels?.map(level => level.effort)).toEqual(["none", "high"]); + expect(catalog.default_reasoning_level).toBe("high"); + } + expect(provider.modelReasoningEfforts).toBeUndefined(); + expect(provider.modelDefaultReasoningEfforts).toBeUndefined(); + }); + test.each([ + { adapter: "openai-chat", baseUrl: "https://api.xiaomimimo.com/v1" }, + { adapter: "anthropic", baseUrl: "https://api.xiaomimimo.com/anthropic" }, + ])("destination capability overrides remain authoritative on $adapter", transport => { + const id = "mimo-v2.6-pro"; + const provider: OcxProviderConfig = { ...transport, modelCapabilities: { [id]: { supportsThinkingToggle: false, defaultThinkingEnabled: false } } }; + enrichProviderFromRegistry("my-mimo", provider); + expect(resolveReasoningCapabilities(provider, id)).toEqual({ supportsReasoning: true, supportsThinkingToggle: false, supportsReasoningEffort: false, defaultThinkingEnabled: false }); + expect(resolveReasoningCapabilities({ ...transport, baseUrl: "https://foreign.example.test/v1" }, id)).toEqual({}); + const custom = resolveReasoningCapabilities(transport, `${id}-custom`); + expect(custom.supportsThinkingToggle).toBeUndefined(); + expect(custom.defaultThinkingEnabled).toBeUndefined(); + expect(custom.supportsReasoningEffort).not.toBe(false); + }); + test("explicit real effort opt-in preserves case-folded legacy ladder overrides", () => { + const id = "mimo-v2.6-pro"; + const provider: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://api.xiaomimimo.com/v1", + modelCapabilities: { [id]: { supportsReasoningEffort: true } }, + modelReasoningEfforts: { "MIMO-V2.6-PRO": ["low", "high"] }, + modelDefaultReasoningEfforts: { "MIMO-V2.6-PRO": "low" } }; + enrichProviderFromRegistry("my-mimo", provider); + expect(applyProviderConfigHints("my-mimo", provider, { provider: "my-mimo", id })) + .toMatchObject({ supportsReasoningEffort: true, reasoningEfforts: ["low", "high"], defaultReasoningEffort: "low" }); + }); +}); const FLASH = { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 }; const entry = (id: string) => PROVIDER_REGISTRY.find(row => row.id === id)!; diff --git a/tests/providers/resolved-model-policy.test.ts b/tests/providers/resolved-model-policy.test.ts index d186f870754..9d9884be65a 100644 --- a/tests/providers/resolved-model-policy.test.ts +++ b/tests/providers/resolved-model-policy.test.ts @@ -863,3 +863,80 @@ describe("resolved static model policy parity", () => { expect(entry.modelContextWindows?.[MODEL]).toBe(100_000); }); }); + +describe("independent reasoning policy", () => { + const id = "synthetic-reasoner"; + const entry: ProviderRegistryEntry = { + id: "synthetic-reasoner", label: "Synthetic reasoner", adapter: "openai-chat", + baseUrl: "https://synthetic.example.test/v1", authKind: "key", + reasoningEfforts: ["low", "medium", "high"], + modelCapabilities: { [id]: { + supportsReasoning: true, supportsThinkingToggle: true, supportsReasoningEffort: false, defaultThinkingEnabled: true, + } }, + thinkingToggleModels: [id], + }; + test("registry axes fill beneath exact operator axes without synthetic strength defaults", () => { + const provider: OcxProviderConfig = { adapter: entry.adapter, baseUrl: entry.baseUrl, + modelCapabilities: { [id]: { supportsThinkingToggle: false, defaultThinkingEnabled: false } }, + modelReasoningEfforts: { [id]: ["low", "high"] }, modelDefaultReasoningEfforts: { [id]: "high" } }; + const policy = resolveModelPolicy({ providerName: "synthetic", modelId: id, provider, registryEntry: entry, transportMatchedRegistry: true }); + expect(policy.model).toMatchObject({ supportsReasoning: true, supportsThinkingToggle: false, + supportsReasoningEffort: false, defaultThinkingEnabled: false, reasoningEfforts: [] }); + expect(policy.model.defaultReasoningEffort).toBeUndefined(); + expect(policy.provider.modelReasoningEfforts?.[id]).toEqual(["low", "high"]); + expect(policy.provenance.model.supportsThinkingToggle).toBe("operator-capability"); + expect(policy.provenance.model.supportsReasoningEffort).toBe("registry"); + expect(entry.modelCapabilities?.[id]?.supportsThinkingToggle).toBe(true); + }); + test.each(["synthetic-reasoner", "SYNTHETIC-REASONER"])("case-varied capability overrides remain authoritative for %s", modelId => { + const provider: OcxProviderConfig = { adapter: entry.adapter, baseUrl: entry.baseUrl, + modelCapabilities: { [id.toUpperCase()]: { supportsThinkingToggle: false, defaultThinkingEnabled: false } } }; + const policy = resolveModelPolicy({ providerName: "synthetic", modelId, provider, registryEntry: entry, transportMatchedRegistry: true }); + expect(policy.model).toMatchObject({ supportsReasoning: true, supportsThinkingToggle: false, + supportsReasoningEffort: false, defaultThinkingEnabled: false }); + expect(policy.provenance.model.supportsThinkingToggle).toBe("operator-capability"); + expect(policy.provenance.model.supportsReasoningEffort).toBe("registry"); + expect(Object.keys(policy.provider.modelCapabilities ?? {})).toHaveLength(1); + const overlay = resolveModelPolicy({ providerName: "synthetic", modelId, provider, registryEntry: entry, + transportMatchedRegistry: true, modelCapabilities: { defaultThinkingEnabled: true } }); + expect(overlay.model).toMatchObject({ supportsReasoning: true, supportsThinkingToggle: false, + supportsReasoningEffort: false, defaultThinkingEnabled: true }); + expect(entry.modelCapabilities?.[id]?.supportsThinkingToggle).toBe(true); + expect(provider.modelCapabilities?.[id.toUpperCase()]).toEqual({ supportsThinkingToggle: false, defaultThinkingEnabled: false }); + }); + test("case-folded capability declarations govern discovered rows without changing unknown legacy evidence", () => { + const provider: OcxProviderConfig = { + adapter: entry.adapter, + baseUrl: entry.baseUrl, + modelCapabilities: { M: { supportsReasoning: false } }, + }; + const discovered = { + provider: "synthetic", + id: "m", + supportsReasoning: true, + supportsThinkingToggle: true, + supportsReasoningEffort: true, + reasoningEfforts: ["low", "high"], + }; + const policy = resolveModelPolicy({ providerName: "synthetic", modelId: "m", provider, registryEntry: entry, transportMatchedRegistry: true }); + expect(policy.model).toMatchObject({ supportsReasoning: false, supportsThinkingToggle: false, supportsReasoningEffort: false }); + expect(applyProviderConfigHints("synthetic", provider, discovered)).toMatchObject({ + supportsReasoning: false, + supportsThinkingToggle: false, + supportsReasoningEffort: false, + reasoningEfforts: [], + }); + expect(applyProviderConfigHints("synthetic", { adapter: entry.adapter, baseUrl: entry.baseUrl }, discovered)).toMatchObject(discovered); + }); + test.each(["operator", "registry"])("%s noReasoningModels remains authoritative over every independent declaration", source => { + const provider: OcxProviderConfig = { adapter: entry.adapter, baseUrl: entry.baseUrl, + modelCapabilities: { [id]: { supportsThinkingToggle: true } }, + ...(source === "operator" ? { noReasoningModels: [id] } : {}) }; + const registryEntry = source === "registry" ? { ...entry, noReasoningModels: [id] } : entry; + const policy = resolveModelPolicy({ providerName: "synthetic", modelId: id, provider, registryEntry, transportMatchedRegistry: true }); + expect(policy.model).toMatchObject({ supportsReasoning: false, supportsThinkingToggle: false, + supportsReasoningEffort: false, defaultThinkingEnabled: false, reasoningEfforts: [] }); + expect(policy.provenance.model).toMatchObject({ supportsReasoning: source, supportsThinkingToggle: source, + supportsReasoningEffort: source, defaultThinkingEnabled: source }); + }); +}); diff --git a/tests/responses/chat-inbound-reasoning-none.test.ts b/tests/responses/chat-inbound-reasoning-none.test.ts index 4a453ccd558..cc627d7784c 100644 --- a/tests/responses/chat-inbound-reasoning-none.test.ts +++ b/tests/responses/chat-inbound-reasoning-none.test.ts @@ -22,6 +22,16 @@ function reasoningOf(body: Record): Record | u return body.reasoning as Record | undefined; } +test("Chat thinking toggles survive translation independently of effort", () => { + for (const enabled of [true, false]) { + const native = chatCompletionsToResponsesBody(chat({ thinking: { type: enabled ? "enabled" : "disabled" } })); + expect(reasoningOf(native)).toMatchObject({ enabled }); + expect(reasoningOf(native)?.effort).toBeUndefined(); + const both = chatCompletionsToResponsesBody(chat({ reasoning: { enabled, effort: "low" } })); + expect(reasoningOf(both)).toMatchObject({ enabled, effort: "low" }); + } +}); + describe("F7 explicit reasoning disable survives the Chat boundary", () => { test("preserves a flat reasoning_effort of none", () => { const body = chatCompletionsToResponsesBody(chat({ reasoning_effort: "none" })); diff --git a/tests/responses/openai-responses-summary-none.test.ts b/tests/responses/openai-responses-summary-none.test.ts index f052c8fe07a..2b94f747c40 100644 --- a/tests/responses/openai-responses-summary-none.test.ts +++ b/tests/responses/openai-responses-summary-none.test.ts @@ -1,15 +1,17 @@ import { describe, expect, test } from "bun:test"; import { createResponsesPassthroughAdapter } from "../../src/adapters/openai-responses"; import { anthropicToResponsesBody } from "../../src/claude/inbound"; +import { chatCompletionsToResponsesBody } from "../../src/chat/inbound"; import { parseRequest } from "../../src/responses/parser"; +import type { OcxProviderConfig } from "../../src/types"; import { withTestTranslatorBudget } from "../helpers/translator-budget"; -const targets = [ +const targets: OcxProviderConfig[] = [ { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" as const }, { adapter: "openai-responses", baseUrl: "https://api.openai.com/v1", authMode: "key" as const, apiKey: "sk-test" }, ]; -function outboundReasoning(target: (typeof targets)[number], body: Record): Record | undefined { +function outboundReasoning(target: OcxProviderConfig, body: Record): Record | undefined { const adapter = withTestTranslatorBudget(createResponsesPassthroughAdapter(target)); const request = adapter.buildRequest(parseRequest(body), { headers: new Headers() }); return (JSON.parse(request.body) as { reasoning?: Record }).reasoning; @@ -39,4 +41,47 @@ describe("Responses summary:none wire marker", () => { .toEqual({ effort: "medium", summary }); } }); + + test("strips the internal thinking toggle unless a custom Responses wire declares it", () => { + const bodies = [ + { model: "gpt-6-astra", input: "hi", reasoning: { enabled: false, effort: "high", summary: "detailed" } }, + chatCompletionsToResponsesBody({ + model: "gpt-6-astra", + messages: [{ role: "user", content: "hi" }], + reasoning: { enabled: true, effort: "low" }, + }), + anthropicToResponsesBody({ + model: "gpt-6-astra", + max_tokens: 10, + messages: [{ role: "user", content: "hi" }], + thinking: { type: "enabled" }, + output_config: { effort: "medium" }, + }), + ]; + + for (const body of bodies) { + const reasoning = body.reasoning as Record; + for (const target of targets) { + expect(outboundReasoning(target, body)).toEqual( + Object.fromEntries(Object.entries(reasoning).filter(([key]) => key !== "enabled")), + ); + } + } + + const routed = { adapter: "openai-responses", baseUrl: "https://compat.example.test/v1", authMode: "key" as const }; + for (const body of bodies) { + expect(outboundReasoning(routed, body)).toEqual( + Object.fromEntries(Object.entries(body.reasoning as Record).filter(([key]) => key !== "enabled")), + ); + expect(outboundReasoning({ ...routed, reasoningWireFormat: "gateway-object" }, body)).toEqual(body.reasoning); + } + const toggleOnly = { model: "gpt-6-astra", input: "hi", reasoning: { enabled: false } }; + for (const target of targets) expect(outboundReasoning(target, toggleOnly)).toBeUndefined(); + expect(toggleOnly.reasoning).toEqual({ enabled: false }); + expect(outboundReasoning(routed, toggleOnly)).toBeUndefined(); + expect(outboundReasoning({ ...routed, reasoningWireFormat: "gateway-object" }, toggleOnly)).toEqual({ enabled: false }); + for (const target of targets) { + expect(outboundReasoning({ ...target, reasoningWireFormat: "gateway-object" }, toggleOnly)).toBeUndefined(); + } + }); }); diff --git a/tests/responses/responses-native-ultra-effort-wire.test.ts b/tests/responses/responses-native-ultra-effort-wire.test.ts index dd1eb9fdd55..25c57c4485b 100644 --- a/tests/responses/responses-native-ultra-effort-wire.test.ts +++ b/tests/responses/responses-native-ultra-effort-wire.test.ts @@ -13,6 +13,10 @@ import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/iso import { ownedServiceHomeInspection } from "../helpers/owned-service-home-inspection"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { SERVER_BUDGET_MS } from "../helpers/test-budget"; +import { parseRequest } from "../../src/responses/parser"; +import { routeModel } from "../../src/router"; +import { applyFinalRouteRequestNormalization } from "../../src/server/responses/core-normalize"; +import { applyCompactionRoutingOverride } from "../../src/server/responses/compaction-routing"; /** * #7590: the catalog offers `ultra` on native rows as a client-facing choice, but the ChatGPT @@ -77,6 +81,7 @@ describe("native ultra effort reaches the ChatGPT backend as max", () => { baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "direct", + noReasoningModels: ["gpt-6.1-sol"], }, }, } as OcxConfig); @@ -137,4 +142,50 @@ describe("native ultra effort reaches the ChatGPT backend as max", () => { await response.text(); expect(efforts).toEqual(["max"]); }, SERVER_BUDGET_MS); + + test("saved noReasoningModels cannot bypass the native none-to-low wire clamp", async () => { + const response = await post("/v1/responses", { + model: "gpt-6.1-sol", stream: false, input: "hi", reasoning: { effort: "none" }, + }); + expect(response.status).toBe(200); + await response.text(); + expect(efforts).toEqual(["low"]); + }, SERVER_BUDGET_MS); +}); + +describe("native capability normalization keeps parsed and raw effort aligned", () => { + test.each(["direct", "compaction-switch", "failover"])("%s retains the native clamp with saved exclusions", async path => { + const config: OcxConfig = { + port: 0, defaultProvider: "openai", + providers: { + openai: { adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex", noReasoningModels: ["gpt-6.1-sol"] }, + gateway: { adapter: "openai-responses", authMode: "key", baseUrl: "https://example.test/v1", reasoningEfforts: [], + modelCapabilities: { model: { supportsReasoning: true, supportsThinkingToggle: true, supportsReasoningEffort: false } } }, + }, + compactionRouting: { model: "gpt-6.1-sol", reasoningEffort: "none" }, + }; + const body: Record = { + model: path === "direct" ? "gpt-6.1-sol" : "gateway/model", + input: [{ role: "user", content: "hi" }, ...(path === "compaction-switch" ? [{ type: "compaction_trigger" }] : [])], + reasoning: { effort: "none", summary: "auto" }, + }; + const req = new Request("http://localhost/v1/responses", { headers: { + "x-codex-turn-metadata": JSON.stringify({ request_kind: "compaction", compaction: { trigger: "manual" } }), + } }); + if (path === "compaction-switch") expect(applyCompactionRoutingOverride(body, req.headers, config)).not.toBeNull(); + const parsed = parseRequest(body); + const normalize = async (model: string) => { + const route = routeModel(config, model); + await applyFinalRouteRequestNormalization({ parsed, route, config, req, inboundWire: "responses", logCtx: { model, provider: route.providerName } }); + }; + if (path === "failover") { + await normalize("gateway/model"); + expect(parsed.options.reasoning).toBeUndefined(); + expect(parsed.options.thinkingEnabled).toBe(false); + expect((parsed._rawBody as { reasoning: unknown }).reasoning).toEqual({ summary: "auto" }); + } + await normalize("gpt-6.1-sol"); + expect(parsed.options.reasoning).toBe("low"); + expect((parsed._rawBody as { reasoning: unknown }).reasoning).toEqual({ effort: "low", summary: "auto" }); + }); }); diff --git a/tests/responses/responses-parser.test.ts b/tests/responses/responses-parser.test.ts index 235c5ed91d8..93698c8b5d3 100644 --- a/tests/responses/responses-parser.test.ts +++ b/tests/responses/responses-parser.test.ts @@ -7,8 +7,62 @@ import { buildTools } from "../../src/responses/parser-tools"; import { parseTextFormat } from "../../src/responses/parser-text-format"; import { buildToolBridgeMaps } from "../../src/server/responses"; import { repoPath } from "../helpers/repo-root"; +import { normalizeThinkingOptions, resolveThinkingEnabled } from "../../src/clients/reasoning-controls"; +import type { OcxProviderConfig } from "../../src/types"; + +describe("generic effort-only client compatibility", () => { + const provider: OcxProviderConfig = { + adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1", + modelCapabilities: { fixed: { supportsReasoning: true, supportsThinkingToggle: true, supportsReasoningEffort: false, defaultThinkingEnabled: true } }, + }; + test("decodes switch aliases without a vendor identity or a real strength ladder", () => { + expect(resolveThinkingEnabled(provider, "fixed", { reasoning: "none" })).toBe(false); + expect(resolveThinkingEnabled(provider, "fixed", { reasoning: "high" })).toBe(true); + expect(resolveThinkingEnabled(provider, "fixed", {})).toBe(true); + expect(resolveThinkingEnabled(provider, "fixed", { thinkingEnabled: false, reasoning: "high" })).toBe(false); + expect(resolveThinkingEnabled(provider, "fixed", { thinkingEnabled: true, reasoning: "none" })).toBe(true); + }); + test("keeps real effort independent from a switch and does not invent missing controls", () => { + const both = { ...provider, modelCapabilities: { fixed: { supportsReasoning: true, supportsThinkingToggle: true, supportsReasoningEffort: true } } }; + expect(resolveThinkingEnabled(both, "fixed", { reasoning: "high" })).toBe(true); + expect(resolveThinkingEnabled(both, "fixed", { reasoning: "none" })).toBe(false); + expect(resolveThinkingEnabled(both, "fixed", { thinkingEnabled: false, reasoning: "high" })).toBe(false); + expect(resolveThinkingEnabled({ ...provider, modelCapabilities: { fixed: { supportsReasoning: true, supportsThinkingToggle: false } } }, "fixed", { reasoning: "none" })).toBeUndefined(); + expect(resolveThinkingEnabled(provider, "unknown", { reasoning: "high" })).toBeUndefined(); + }); + test("honors explicit compatibility mappings and leaves an empty strength list independent", () => { + expect(resolveThinkingEnabled({ ...provider, modelReasoningEfforts: { fixed: [] } }, "fixed", { reasoning: "none" })).toBe(false); + expect(resolveThinkingEnabled({ ...provider, reasoningEffortMap: { high: "__omit__" } }, "fixed", { reasoning: "high" })).toBeUndefined(); + expect(resolveThinkingEnabled({ ...provider, reasoningEffortMap: { high: "none" } }, "fixed", { reasoning: "high" })).toBe(false); + expect(resolveThinkingEnabled({ ...provider, reasoningEffortMap: { none: "high" } }, "fixed", { reasoning: "none" })).toBe(true); + }); + test("canonical negative declarations constrain requests while unknown axes remain untouched", () => { + for (const flags of [{ supportsReasoning: false }, { supportsThinkingToggle: false, supportsReasoningEffort: false }]) { + const options = { thinkingEnabled: true, reasoning: "high" }; + normalizeThinkingOptions({ ...provider, modelCapabilities: { fixed: flags } }, "fixed", options); + expect(options).toEqual({}); + } + const both = { ...provider, modelCapabilities: { fixed: { supportsReasoning: true, supportsThinkingToggle: true, supportsReasoningEffort: true, defaultThinkingEnabled: false } } }; + const options = { reasoning: "low" }; + normalizeThinkingOptions(both, "fixed", options); + expect(options).toEqual({ thinkingEnabled: true, reasoning: "low" }); + const unknown = { thinkingEnabled: true, reasoning: "high" }; + normalizeThinkingOptions(provider, "unknown", unknown); + expect(unknown).toEqual({ thinkingEnabled: true, reasoning: "high" }); + }); +}); describe("Responses parser", () => { + test("keeps thinking enablement separate from effort and summary visibility", () => { + const on = parseRequest({ model: "fixed-depth", input: "test", reasoning: { enabled: true } }); + expect(on.options.thinkingEnabled).toBe(true); + expect(on.options.reasoning).toBeUndefined(); + expect(on.options.hideThinkingSummary).toBeUndefined(); + const off = parseRequest({ model: "adjustable", input: "test", reasoning: { enabled: false, effort: "high" } }); + expect(off.options.thinkingEnabled).toBe(false); + expect(off.options.reasoning).toBe("high"); + expect(off.options.hideThinkingSummary).toBe(true); + }); test("normalizes function tool schemas to an object root without corrupting valid schemas (#745)", () => { const validParameters = { type: "object", diff --git a/tests/server/api-catalog-route.test.ts b/tests/server/api-catalog-route.test.ts index f05dcb1febd..aeb61cc8199 100644 --- a/tests/server/api-catalog-route.test.ts +++ b/tests/server/api-catalog-route.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { handleManagementAPI } from "../../src/server/management-api"; @@ -7,6 +7,8 @@ import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/iso import { ManagementRequest, managementHeaders } from "../helpers/management-auth"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import type { ManagementModelRow } from "../../src/server/management/model-rows"; +import type { KiloGeneratedConfig, OpencodeGeneratedConfig } from "../../src/clients/config-export"; const TEST_DIR = join(import.meta.dir, `.tmp-api-catalog-route-${process.pid}`); const previousOpencodexHome = process.env.OPENCODEX_HOME; @@ -108,6 +110,115 @@ describe("GET /api/catalog route (#709)", () => { }); }); +test("static Xiaomi MiMo capability truth, client exports and endpoint thinking controls", async () => { + isolatedCodexHome = installIsolatedCodexHome("ocx-mimo-catalog-export-"); + process.env.OPENCODEX_HOME = join(isolatedCodexHome.path, "proxy"); + const ids = ["mimo-v2.6-pro", "mimo-v2.6-flash", "mimo-v2.6-pro-ultraspeed"]; + const realFetch = globalThis.fetch; + const blockedRequests: string[] = []; + const inferenceBodies: Record[] = []; + // Only the captured fetch below can reach our local test server. Every fetch made by + // production code is stubbed, including discovery, background work and paid endpoints. + const upstreamFetch = spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + blockedRequests.push(url); + if (new URL(url).hostname === "api.xiaomimimo.com" && new URL(url).pathname.endsWith("/chat/completions")) { + inferenceBodies.push(JSON.parse(String(init?.body ?? (input instanceof Request ? await input.text() : "{}")))); + return Response.json({ id: "mock-chat", object: "chat.completion", created: 1, model: ids[0], + choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } }); + } + return Response.json({ data: [] }); + }); + let server: ReturnType | undefined; + try { + saveConfig({ + port: 0, hostname: "127.0.0.1", defaultProvider: "xiaomi-mimo", fastRows: false, + providers: { "xiaomi-mimo": { + adapter: "openai-chat", baseUrl: "https://api.xiaomimimo.com/v1", + apiKey: "test-only-mimo-key", authMode: "key", liveModels: false, models: ids, + } }, + } as OcxConfig); + const { startServer } = await import("../../src/server"); + server = startServer(0); + const publicResponse = await realFetch(new URL("/v1/models", server.url)); + expect(publicResponse.status).toBe(200); + const publicList = await publicResponse.json() as { + data: Array<{ id: string; capabilities?: { supports_reasoning?: boolean; supports_thinking_toggle?: boolean; + supports_reasoning_effort?: boolean; default_thinking_enabled?: boolean; reasoning_effort?: string[] } }>; + }; + for (const id of ids) { + const capabilities = publicList.data.find(row => row.id === `xiaomi-mimo/${id}`)?.capabilities; + expect(capabilities).toMatchObject({ + supports_reasoning: true, supports_thinking_toggle: true, supports_reasoning_effort: false, + default_thinking_enabled: true, + }); + expect(capabilities).not.toHaveProperty("reasoning_effort"); + expect(capabilities).not.toHaveProperty("reasoning_control"); + } + + const rowsResponse = await realFetch(new URL("/api/models", server.url), { headers: managementHeaders() }); + expect(rowsResponse.status).toBe(200); + const rows = await rowsResponse.json() as ManagementModelRow[]; + for (const id of ids) { + const row = rows.find(row => row.namespaced === `xiaomi-mimo/${id}`); + expect(row).toMatchObject({ + supportsReasoning: true, supportsThinkingToggle: true, supportsReasoningEffort: false, defaultThinkingEnabled: true, + }); + expect(row?.reasoningEfforts ?? []).toEqual([]); + expect(row?.exportMetadata?.reasoningEfforts ?? []).toEqual([]); + expect(row?.exportMetadata?.defaultReasoningEffort).toBeUndefined(); + } + + for (const client of ["opencode", "kilo"]) { + const response = await realFetch(new URL(`/api/client-config?client=${client}`, server.url), { headers: managementHeaders() }); + expect(response.status).toBe(200); + const { config } = await response.json() as { config: OpencodeGeneratedConfig | KiloGeneratedConfig }; + for (const id of ids) { + const entry = config.provider.opencodex!.models[`xiaomi-mimo/${id}`]!; + expect(entry.reasoning).toBe(true); + expect(entry.options).toEqual({ reasoningEffort: "high" }); + expect(entry.variants!.none).toEqual({ reasoningEffort: "none" }); + expect(entry.variants!.high).toEqual({ reasoningEffort: "high" }); + for (const effort of ["minimal", "low", "medium", "xhigh", "max", "ultra"]) { + expect(entry.variants![effort]).toEqual({ disabled: true }); + } + if (client === "opencode") { + const v2 = (config as OpencodeGeneratedConfig).providers.opencodex!.models[`xiaomi-mimo/${id}`]!; + expect(v2.variants.map(variant => variant.id)).toEqual(["none", "high"]); + expect(v2.settings).toEqual({ reasoningEffort: "high" }); + } + } + } + expect(blockedRequests.filter(url => new URL(url).hostname === "api.xiaomimimo.com")).toEqual([]); + for (const path of ["/v1/responses", "/v1/chat/completions"]) { + for (const [control, enabled] of [ + [{ reasoning: { enabled: false, effort: "high" } }, false], + [{ reasoning: { enabled: true, effort: "none" } }, true], + [path === "/v1/responses" ? { reasoning: { effort: "none" } } : { reasoning_effort: "none" }, false], + [path === "/v1/responses" ? { reasoning: { effort: "high" } } : { reasoning_effort: "high" }, true], + ] as const) { + const before = inferenceBodies.length; + const response = await realFetch(new URL(path, server.url), { method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: `xiaomi-mimo/${ids[0]}`, stream: false, + ...(path === "/v1/responses" ? { input: "hello" } : { messages: [{ role: "user", content: "hello" }] }), + ...control }), + }); + await response.text(); + expect(response.status).toBe(200); + expect(inferenceBodies.length).toBe(before + 1); + expect({ path, control, thinking: inferenceBodies.at(-1)?.thinking }) + .toEqual({ path, control, thinking: { type: enabled ? "enabled" : "disabled" } }); + expect(inferenceBodies.at(-1)).not.toHaveProperty("reasoning_effort"); + } + } + expect(blockedRequests.filter(url => new URL(url).hostname === "api.xiaomimimo.com")).toHaveLength(8); + } finally { + try { await server?.stop(true); } finally { upstreamFetch.mockRestore(); } + } +}, 30_000); + describe("GET|HEAD /v1/catalog least-privilege data-plane route (#809)", () => { const DATA_KEY = "ocx_data_catalogreadonly"; From 9a61bd96b91da804ebc5ac8303a14b8988a8f6a9 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Mon, 26 Oct 2026 19:43:59 +0000 Subject: [PATCH 094/101] fix(codex): fence uncertain catalog entry identity (#7610) * fix(codex): preserve hardlink names in catalog heal paths * fix(codex): retain native catalog leaf aliases where safe * fix(codex): fence uncertain catalog entry identity * fix(codex): retain uncertainty after catalog hop exhaustion * fix(catalog): retain uncertainty after symlink lookup failures * fix(test): promote Bun 1.4.4 with independent runner role verification * docs: align Docker Bun comments with unified role defaults * test: use a draining TLS origin for early upload replies * test: confine draining origin to buffered HTTP/1 upload * fix(codex): resolve catalog link target parents with filesystem semantics A relative symlink target containing .. was normalized lexically, so a symlinked intermediate directory made the comparator follow the wrong parent. Walk each intermediate component within the existing read budget: resolve symlinked or missing directories with realpath, treat non-directories and failures as unknown, and keep the leaf for the next lstat hop. * fix(codex): match platform .. semantics in catalog link targets Measured OS behavior splits by platform: Windows normalizes .. lexically across junctions/reparse points and even past missing components, while POSIX resolves each directory and refuses a missing crossing. Keep lexical resolve on Windows; on POSIX walk each intermediate component inside the read budget, record intermediate symlinks in the revalidation snapshot, and treat missing or non-directory crossings as unknown. Regressions assert the independent readFileSync/realpathSync result before the comparator verdict, and the POSIX-only literal-backslash case is an explicit platform skip. * fix(codex): refuse non-directory crossings in POSIX link targets A symlinked intermediate that resolves to a regular file must fail the traversal with ENOTDIR on POSIX; dirname() after realpath would have accepted the wrong catalog. Stat the resolved intermediate inside the read budget, require a directory, and record the resolved identity in the link snapshot so revalidation also catches a target swap. * test(codex): reproduce catalog observation and reanchor regressions * fix(codex): revalidate catalog evidence and reanchor selected publications * fix(codex): retain recovery baseline when publication proof fails * fix(codex): bind heal baselines to published entry evidence * fix(codex): keep the validated heal publication selection * fix(codex): capture publication identity before comparisons --------- Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> --- .../test-layout/layout-codex-collision.json | 1 + src/codex/catalog-self-heal.ts | 66 +- src/codex/catalog/heal-observation.ts | 168 ++++- src/codex/convergence.ts | 4 +- structure/codex-home.md | 4 +- .../codex-catalog-heal-integration.test.ts | 19 + .../codex-catalog-heal-reanchor.test.ts | 485 +++++++++++++++ .../codex-catalog-self-heal.test.ts | 585 +++++++++++++++++- tests/fixtures/test-layout-expected.json | 1 + 9 files changed, 1289 insertions(+), 44 deletions(-) create mode 100644 tests/codex-integration/codex-catalog-heal-reanchor.test.ts diff --git a/scripts/test-layout/layout-codex-collision.json b/scripts/test-layout/layout-codex-collision.json index b35d340f1f7..be04705f588 100644 --- a/scripts/test-layout/layout-codex-collision.json +++ b/scripts/test-layout/layout-codex-collision.json @@ -1,4 +1,5 @@ { + "codex-catalog-heal-reanchor.test.ts": "codex-integration", "codex-quota-persistence.test.ts": "codex-integration", "codex-sync-user-edits.test.ts": "codex-integration", "codex-stored-main-duplicate.test.ts": "codex-integration", diff --git a/src/codex/catalog-self-heal.ts b/src/codex/catalog-self-heal.ts index 9fda96edd6b..6ee264f10f9 100644 --- a/src/codex/catalog-self-heal.ts +++ b/src/codex/catalog-self-heal.ts @@ -3,7 +3,7 @@ import { readRuntimePort } from "../config/process-state"; import { readClientConnectionState } from "../client/state"; import { getActiveTurnCount, isDraining, isRecyclingForExit, isShutdownDraining } from "../server/lifecycle"; import type { OcxConfig } from "../types"; -import { observeCatalogHealFile, sameCatalogHealPath, selectCatalogHealPath, type CatalogObservation } from "./catalog/heal-observation"; +import { compareCatalogHealPaths, observeCatalogHealFile, retainCatalogHealPath, selectCatalogHealPath, type CatalogObservation } from "./catalog/heal-observation"; import { subscribeCatalogPublication } from "./catalog/publication-observer"; import { configEnablesRoutedNamespace, ocxRoutedNamespaceCounts } from "./catalog/routed-removal"; import { inspectCodexHomeOwner } from "./codex-home-owner"; @@ -51,6 +51,7 @@ export interface CatalogSelfHealDeps { now?: () => number; catalogPath?: () => string | null; observe?: (path: string, readContent: boolean) => CatalogObservation | null; + retainPath?: typeof retainCatalogHealPath; converge?: (config: OcxConfig, lifecycle: CatalogHealLifecycle) => Promise; subscribe?: typeof subscribeCatalogPublication; gates?: Partial; @@ -135,14 +136,19 @@ export function startCodexCatalogSelfHeal(options: { port?: number; deps?: Catal const attempts: number[] = []; const heals: number[] = []; - const read = (path: string): NamespaceObservation | null => { + const read = (path: string, fresh = false): NamespaceObservation | null => { const quick = observe(path, false); if (quick === null) return null; - if (observed !== null && sameCatalogHealPath(observed.path, path) && observed.signature === quick.signature) return observed; + if (!fresh && observed !== null) { + const relation = compareCatalogHealPaths(observed.path, path); + if (relation === "unknown") return null; + if (relation === "same" && observed.signature === quick.signature) return observed; + } const seen = observe(path, true); if (seen === null) return null; - observed = { path, signature: seen.signature, namespaces: seen.catalog ? new Set(ocxRoutedNamespaceCounts(seen.catalog).keys()) : null }; - return observed; + const result = { path, signature: seen.signature, namespaces: seen.catalog ? new Set(ocxRoutedNamespaceCounts(seen.catalog).keys()) : null }; + if (!fresh) observed = result; + return result; }; const accept = (path: string): void => { const seen = read(path); @@ -154,18 +160,23 @@ export function startCodexCatalogSelfHeal(options: { port?: number; deps?: Catal pendingRetry = null; recheckAt = 0; }; - const updateTarget = (path: string | null): void => { + const updateTarget = (path: string | null): boolean => { // Unavailable selection is not evidence of a different target or lost authority. - if (path === null) return; - if (target !== null && sameCatalogHealPath(target, path)) return; + if (path === null) return false; + if (target !== null) { + const relation = compareCatalogHealPaths(target, path); + if (relation === "unknown") return false; + if (relation === "same") return true; + } clear(); target = path; + return true; }; const beforeCommit = (path: string, entryGeneration: number): boolean => { try { const selected = catalogPath(); return !stopped && !released && generation === entryGeneration - && selected !== null && sameCatalogHealPath(selected, path) && evaluateCatalogSelfHealGates(gates).open; + && selected !== null && compareCatalogHealPaths(selected, path) === "same" && evaluateCatalogSelfHealGates(gates).open; } catch { return false; } }; const capRetryAt = (now: number): number => { @@ -182,8 +193,7 @@ export function startCodexCatalogSelfHeal(options: { port?: number; deps?: Catal running = true; try { const path = catalogPath(); - updateTarget(path); - if (path === null) return; + if (!updateTarget(path) || path === null) return; // Pending cache/funnel failure is independent of catalog stat or apparent repaired rows. if (pendingRetry) { const config = gates.loadConfig(); @@ -226,7 +236,7 @@ export function startCodexCatalogSelfHeal(options: { port?: number; deps?: Catal })).committed; } catch { /* Failed attempts retain pending retry and consume the attempt budget. */ } const selected = catalogPath(); - if (stopped || released || generation !== entryGeneration || selected === null || !sameCatalogHealPath(selected, path)) return; + if (stopped || released || generation !== entryGeneration || selected === null || compareCatalogHealPaths(selected, path) !== "same") return; last = { at: new Date().toISOString(), lostNamespaces: lostEnabled.length, committed }; if (committed) { heals.push(clock()); @@ -241,33 +251,43 @@ export function startCodexCatalogSelfHeal(options: { port?: number; deps?: Catal const unsubscribe = (deps.subscribe ?? subscribeCatalogPublication)(event => { if (stopped) return; + const release = event.kind === "native-released" || event.intent === "restore"; + // Bind publication evidence before comparisons can observe a retargeted alias. + const stillPublished = !release && event.path !== null ? (deps.retainPath ?? retainCatalogHealPath)(event.path) : null; // Native restoration can remove the journal before notification. Fence the known // target (and target-less releases) without resolving possibly unavailable evidence. - const release = event.kind === "native-released" || event.intent === "restore"; - if (release && (event.path === null || (target !== null && sameCatalogHealPath(event.path, target)))) { + const known = event.path !== null && target !== null ? compareCatalogHealPaths(event.path, target) : "different"; + if (release && (event.path === null || known !== "different")) { clear(); released = true; return; } const path = catalogPath(); - if (event.path !== null && (target === null || !sameCatalogHealPath(event.path, target)) - && (path === null || !sameCatalogHealPath(event.path, path))) return; + const selected = event.path !== null && path !== null ? compareCatalogHealPaths(event.path, path) : "different"; + if (event.path !== null && known === "different" && selected === "different") return; if (release) { clear(); released = true; // Only a later accepted owner publication can re-arm this lifecycle. return; } - if (running) return; // Catalog-success/cache-failure must not accept a baseline. + if (running) { + if (known === "unknown" || selected === "unknown") clear(); + return; // Catalog-success/cache-failure must not accept a baseline. + } // A committed owner publication supplies a read-only baseline even while heal writes are gated. - if (event.path === null || path === null || !sameCatalogHealPath(event.path, path)) return; - updateTarget(path); + if (event.path === null || path === null || selected !== "same" || stillPublished === null) return; + // Read independently of obsolete evidence, but retire it only after current selection proof. + const seen = read(event.path, true); + const current = catalogPath(); + if (seen?.namespaces == null || current === null + || compareCatalogHealPaths(event.path, current) !== "same" || !stillPublished(seen.signature)) return; + clear(); + target = current; + baseline = observed = seen; released = false; - pendingRetry = null; - recheckAt = 0; - accept(event.path); }); // Capture immediately after startup sync, before a foreign write can become the first tick. - try { updateTarget(catalogPath()); if (target !== null) accept(target); } + try { const path = catalogPath(); if (updateTarget(path) && target !== null) accept(target); } catch { /* Unavailable observation can be retried by the scheduled tick. */ } const schedule = (): void => { if (stopped) return; diff --git a/src/codex/catalog/heal-observation.ts b/src/codex/catalog/heal-observation.ts index 4ec119fd8ca..746baf9647b 100644 --- a/src/codex/catalog/heal-observation.ts +++ b/src/codex/catalog/heal-observation.ts @@ -1,4 +1,5 @@ -import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpathSync, type Stats } from "node:fs"; +import { closeSync, constants, fstatSync, lstatSync, openSync, readlinkSync, readSync, realpathSync, statSync, type BigIntStats } from "node:fs"; +import { basename, dirname, isAbsolute, join, parse, resolve } from "node:path"; import { readCodexHomeJournal } from "../codex-home-owner"; import { samePath, type RawCatalog } from "./parsing"; @@ -9,10 +10,151 @@ export interface CatalogObservation { readonly catalog: RawCatalog | null; } -/** Canonical path equality when available; unavailable paths retain lexical equality. */ +export type CatalogHealPathComparison = "same" | "different" | "unknown"; +export const CATALOG_HEAL_MAX_LINKS = 40; +// Small catalog-path comparisons need only a handful of reads; unusual paths defer. +export const CATALOG_HEAL_MAX_PATH_READS = 64; +type PathRead = (read: () => T) => T; +type LinkObservation = { path: string; stat: BigIntStats; value: string; parent: string; parentStat: BigIntStats; resolved?: string; resolvedStat?: BigIntStats }; +type PathObservation = { + kind: "native" | "hardlink"; path: string; stat: BigIntStats; links: LinkObservation[]; + canonical: string; parent: string; parentStat: BigIntStats; +} | { kind: "unavailable"; hardlink: boolean }; + +function unchanged(before: BigIntStats, after: BigIntStats): boolean { + return before.dev === after.dev && before.ino === after.ino && before.mode === after.mode + && before.nlink === after.nlink && before.size === after.size + && before.mtimeNs === after.mtimeNs && before.ctimeNs === after.ctimeNs; +} + +/** Windows normalizes ".." lexically across reparse points; POSIX resolves each component. */ +function resolveLinkTarget(base: string, value: string, read: PathRead, links: LinkObservation[]): string { + if (process.platform === "win32") return resolve(base, value); + let current = base; + if (isAbsolute(value)) current = parse(value).root; + const segments = (isAbsolute(value) ? value.slice(parse(value).root.length) : value).split("/"); + for (let index = 0; index < segments.length; index += 1) { + const segment = segments[index]; + if (segment === "" || segment === ".") continue; + if (segment === "..") { current = dirname(current); continue; } + const next = join(current, segment); + if (index === segments.length - 1) return next; + const stat = read(() => lstatSync(next, { bigint: true })); + if (stat.isSymbolicLink()) { + // Keep the intermediate link inside the same revalidation snapshot as leaf hops. + const parent = current, parentStat = read(() => statSync(parent, { bigint: true })), target = read(() => readlinkSync(next)); + const resolvedStat = read(() => statSync(next, { bigint: true })); + // POSIX refuses ".." across a non-directory; statSync follows the link. + if (!resolvedStat.isDirectory()) throw new Error("Link target crosses a non-directory"); + const resolved = read(() => realpathSync.native(next)); + links.push({ path: next, stat, value: target, parent, parentStat, resolved, resolvedStat }); + current = resolved; + } + else if (stat.isDirectory()) current = next; + else throw new Error("Link target crosses a non-directory"); + } + return current; +} + +/** @internal Path observation seam; production callers supply the fixed read budget below. */ +export function inspectHealPath(path: string, read: PathRead): PathObservation { + let hardlink = false; + let observedEntry = false; + const links: LinkObservation[] = []; + try { + for (let count = 0; count < CATALOG_HEAL_MAX_LINKS; count += 1) { + const stat = read(() => lstatSync(path, { bigint: true })); + observedEntry = true; + if (stat.isSymbolicLink()) { + const parent = dirname(path), parentStat = read(() => statSync(parent, { bigint: true })), value = read(() => readlinkSync(path)); + links.push({ path, stat, value, parent, parentStat }); + path = resolveLinkTarget(read(() => realpathSync.native(parent)), value, read, links); + continue; + } + // Keep native leaf aliases outside the macOS multi-link FD-name bug. + hardlink = process.platform === "darwin" && stat.isFile() && stat.nlink > 1n; + const parent = read(() => realpathSync.native(dirname(path))), parentStat = read(() => statSync(parent, { bigint: true })); + if (!parentStat.isDirectory() || !unchanged(parentStat, read(() => statSync(dirname(path), { bigint: true })))) throw new Error("Changed parent"); + return { kind: hardlink ? "hardlink" : "native", path, stat, links, parent, parentStat, + canonical: hardlink ? join(parent, basename(path)) : read(() => realpathSync.native(path)) }; + } + // Hop exhaustion requires identity evidence; it must never fall back to lexical comparison. + return { kind: "unavailable", hardlink: true }; + } catch { /* Observed entry evidence cannot fall back to lexical identity after a failed lookup. */ } + return { kind: "unavailable", hardlink: hardlink || observedEntry }; +} + +function revalidateHealPath(seen: Exclude, read: PathRead): boolean { + if (!unchanged(seen.stat, read(() => lstatSync(seen.path, { bigint: true })))) return false; + for (const link of seen.links) { + if (!unchanged(link.stat, read(() => lstatSync(link.path, { bigint: true }))) || read(() => readlinkSync(link.path)) !== link.value + || !unchanged(link.parentStat, read(() => statSync(link.parent, { bigint: true })))) return false; + const resolved = link.resolved, resolvedStat = link.resolvedStat; + if (resolved !== undefined && resolvedStat !== undefined + && !unchanged(resolvedStat, read(() => statSync(resolved, { bigint: true })))) return false; + } + return unchanged(seen.parentStat, read(() => statSync(seen.parent, { bigint: true }))) + && unchanged(seen.parentStat, read(() => statSync(dirname(seen.path), { bigint: true }))); +} + +/** Retain publication entry/link/parent evidence across its catalog read, within one budget. */ +export function retainCatalogHealPath(path: string): (signature: string) => boolean { + let reads = 0; + const read: PathRead = fn => { + if (reads++ >= CATALOG_HEAL_MAX_PATH_READS) throw new Error("Catalog path read limit reached"); + return fn(); + }; + const seen = inspectHealPath(path, read); + return observedSignature => { + try { + return seen.kind !== "unavailable" && signature(seen.stat) === observedSignature && revalidateHealPath(seen, read); + } catch { return false; } + }; +} + +/** Entry uncertainty is distinct from a proven different path, especially for releases. */ +export function compareCatalogHealPaths(left: string, right: string): CatalogHealPathComparison { + let reads = 0, exhausted = false; + const read: PathRead = fn => { + if (reads >= CATALOG_HEAL_MAX_PATH_READS) { exhausted = true; throw new Error("Catalog path read limit reached"); } + reads += 1; + return fn(); + }; + const a = inspectHealPath(left, read), b = inspectHealPath(right, read); + if (exhausted) return "unknown"; + const needsEntry = a.kind === "hardlink" || b.kind === "hardlink" + || (a.kind === "unavailable" && a.hardlink) || (b.kind === "unavailable" && b.hardlink); + if (!needsEntry) { + if (a.kind === "unavailable" || b.kind === "unavailable") { + try { + if ((a.kind !== "unavailable" && !revalidateHealPath(a, read)) + || (b.kind !== "unavailable" && !revalidateHealPath(b, read))) return "unknown"; + return samePath(left, right) ? "same" : "different"; + } catch { return "unknown"; } + } + const result = samePath(a.canonical, b.canonical) ? "same" : "different"; + // Native normalization must not hide changed entries, links or parents on any platform. + try { + return revalidateHealPath(a, read) && revalidateHealPath(b, read) ? result : "unknown"; + } catch { return "unknown"; } + } + if (a.kind === "unavailable" || b.kind === "unavailable") return "unknown"; + try { + let result: CatalogHealPathComparison = "unknown"; + if (a.stat.dev !== b.stat.dev || a.stat.ino !== b.stat.ino) result = "different"; + else if (a.kind === "hardlink" && b.kind === "hardlink") { + if (a.parentStat.dev !== b.parentStat.dev || a.parentStat.ino !== b.parentStat.ino) result = "different"; + else if (basename(a.path) === basename(b.path)) result = "same"; + // Bun 1.4.3 opendir/Glob eagerly enumerate the whole directory. Without a + // bounded entry-name primitive, aliases and sibling hardlinks stay unknown. + } + return revalidateHealPath(a, read) && revalidateHealPath(b, read) ? result : "unknown"; + } catch { return "unknown"; } +} + +/** Admission-style convenience; release consumers must retain the unknown result. */ export function sameCatalogHealPath(left: string, right: string): boolean { - try { return samePath(realpathSync.native(left), realpathSync.native(right)); } - catch { return samePath(left, right); } + return compareCatalogHealPaths(left, right) === "same"; } /** Resolve authority without reading catalog bytes or cleaning journal evidence. */ @@ -32,29 +174,29 @@ export function selectCatalogHealPath( catch { return null; } } -function signature(stat: Stats): string { - return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`; +function signature(stat: BigIntStats): string { + return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`; } /** Bounded regular descriptor reads; never open a FIFO/device or follow a replaced symlink. */ export function observeCatalogHealFile(path: string, readContent: boolean): CatalogObservation | null { let fd: number | undefined; try { - const before = lstatSync(path); - if (!before.isFile() || before.size > CATALOG_HEAL_MAX_BYTES) return null; + const before = lstatSync(path, { bigint: true }); + if (!before.isFile() || before.size > BigInt(CATALOG_HEAL_MAX_BYTES)) return null; if (!readContent) return { signature: signature(before), catalog: null }; fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0)); - const opened = fstatSync(fd); - if (!opened.isFile() || opened.size > CATALOG_HEAL_MAX_BYTES || signature(before) !== signature(opened)) return null; - const bytes = Buffer.alloc(opened.size + 1); + const opened = fstatSync(fd, { bigint: true }); + if (!opened.isFile() || opened.size > BigInt(CATALOG_HEAL_MAX_BYTES) || signature(before) !== signature(opened)) return null; + const bytes = Buffer.alloc(Number(opened.size) + 1); let length = 0; while (length < bytes.length) { const count = readSync(fd, bytes, length, bytes.length - length, null); if (count === 0) break; length += count; } - if (length !== opened.size || signature(fstatSync(fd)) !== signature(opened) - || signature(lstatSync(path)) !== signature(opened)) return null; + if (length !== Number(opened.size) || signature(fstatSync(fd, { bigint: true })) !== signature(opened) + || signature(lstatSync(path, { bigint: true })) !== signature(opened)) return null; let catalog: RawCatalog | null = null; try { const value: unknown = JSON.parse(bytes.subarray(0, length).toString("utf8")); diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts index 99662a71aa9..dbc09ae9e95 100644 --- a/src/codex/convergence.ts +++ b/src/codex/convergence.ts @@ -1,7 +1,7 @@ import { projectAntigravitySelectedModels } from "../providers/antigravity-effort-families"; import { configuredUltraFastSlugs, retainedFlexPolicyBySlug } from "./catalog/configured-tiers"; import { join } from "node:path"; -import { sameCatalogHealPath } from "./catalog/heal-observation"; +import { compareCatalogHealPaths } from "./catalog/heal-observation"; import { getConfigDir, saveConfigPreservingClaudeCode, ultraFastTierEnabled, websocketsEnabled, withExpectedConfigGenerationSync } from "../config"; import { reconcileSuccessfulModelDiscoveries } from "../providers/new-model-policy"; @@ -675,7 +675,7 @@ export async function commitCodexCatalogCandidate( const guarded = withExpectedConfigGenerationSync(state.generation, () => { const invalid = revalidateCandidate(state); if (invalid) return invalid; - if (lifecycle.expectedCatalogPath !== undefined && !sameCatalogHealPath(state.catalog.path, lifecycle.expectedCatalogPath)) { + if (lifecycle.expectedCatalogPath !== undefined && compareCatalogHealPaths(state.catalog.path, lifecycle.expectedCatalogPath) !== "same") { return { kind: "stale", reason: "target-identity" } as const; } if (lifecycle.beforeCommit && lifecycle.beforeCommit() !== true) return { kind: "stale", reason: "process-local" } as const; diff --git a/structure/codex-home.md b/structure/codex-home.md index cf1dedf5dd7..af775f8652f 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -516,9 +516,9 @@ The audit artifact is `opencodex-catalog-audit.jsonl` under the Codex home. New `src/codex/catalog-self-heal.ts` is preloaded without timers before the owner's startup sync. `src/cli/claude-agent-startup-sync.ts` forwards the sync readiness verdict and starts observation synchronously on successful settlement, before deferred Claude roster or Desktop registry awaits. Successful no-op and OFF syncs establish observation; OFF remains gated until enabled, and failed sync never adopts a baseline. Observation stops before teardown. Its unreferenced 30-second timer observes the selected catalog's file identity and parses bounded regular-file content only after a change. Importing admission or catalog writers starts no timer. -A lost routed namespace is repaired through ordinary management convergence only while its provider remains enabled and the owner is idle: no sibling, shutdown/recycle, connected client or client-owned journal; integration remains enabled, runtime ownership is current, and the Codex-home binding permits this writer. A synchronous precommit guard rechecks lifecycle, path and ownership inside K→C after gathering. Observation reuse, target changes, publication/release, precommit/postcommit and expected-target admission compare native realpaths with `samePath`; if either realpath is unavailable they use lexical `samePath`. Null selections remain unavailable. Directory symlink aliases and Windows casing refer to the same target; distinct paths and hardlinks do not. Missing/corrupt selected custom catalogs remain unavailable; healing does not select another target implicitly. +A lost routed namespace is repaired through ordinary management convergence only while its provider remains enabled and the owner is idle: no sibling, shutdown/recycle, connected client or client-owned journal; integration remains enabled, runtime ownership is current, and the Codex-home binding permits this writer. A synchronous precommit guard rechecks lifecycle, path and ownership inside K→C after gathering. Observation reuse, target changes, publication/release, precommit/postcommit and expected-target admission use `src/codex/catalog/heal-observation.ts` with `same`, `different` and `unknown` results. Windows and singly linked macOS files retain native leaf normalization. Every platform revalidates observed links, resolved entries and parent evidence inside the shared read budget; drift or exhaustion returns unknown. Multiply linked macOS files use bigint file/parent identity and before/after metadata checks, including resolved leaf symlinks. Stable identical leaf names under one parent identify the same entry; different file identities or different parent identities identify different entries. Different leaf spellings under one parent and inode remain unknown: Bun 1.4.3's opendir and Glob both enumerate eagerly, so this comparator performs no directory listing and does not pretend to have bounded entry-name evidence or implement Unicode folding. On Windows, link-target `..` segments keep the OS's lexical normalization across reparse points; on POSIX, intermediate directory components resolve with filesystem semantics inside the same read budget, a `..` segment climbs the real parent rather than the lexical one, intermediate symlinks join the revalidation snapshot with their resolved identity, and a missing or non-directory crossing — including a link that resolves to a regular file — is unknown. Comparisons stop after 64 filesystem API reads or 40 leaf-symlink hops; exhaustion, identity races and unavailable multi-link evidence are unknown. Outside that uncertain branch, ordinary unavailable paths retain lexical `samePath`. Null selections remain unavailable. Missing/corrupt selected custom catalogs remain unavailable; healing does not select another target implicitly. -A matching committed owner publication establishes the baseline even while lifecycle gates prevent healing; baseline acceptance performs no write. Publications during this healer's own convergence remain fenced until its terminal result. Deliberate restore/native release fences pending work and clears retry state; successful owner empty publication is accepted. Failed or refused convergence retains an explicit pending retry, including catalog-success/cache-failure when no missing namespace remains on disk. Retries wait five minutes and obey rolling limits of six attempts and three successful heals per hour. Successful convergence accepts its result, while configuration deletion or a target change retires the old obligation. These are local cooperative recovery rules, not proof of installed Desktop or live-provider behavior. +Once an entry is observed, any subsequent failed path lookup remains unknown, including a dangling link target; lexical fallback applies only before that evidence exists. Admission and precommit accept only proven same paths; unknown target changes and observation reuse defer healing. A committed owner publication captures the published entry's identity before any known-target or selected-target comparison, retaining that same entry, link and parent evidence through the comparisons, content read and acceptance within one bounded snapshot. The returned observation must match the retained full-width device, inode, size and nanosecond timestamp signature; unavailable retained identity refuses acceptance. After the read, the published entry must still match the current selection and its retained evidence must revalidate before a stable catalog observation retires the old generation and establishes a fresh target/baseline even when the obsolete target is unknown. Fresh publication observations remain local until acceptance; failed observation, identity drift, unknown identity or changed selection preserves the previous baseline, target, retry state and any release fence. A matching committed owner publication establishes the baseline even while lifecycle gates prevent healing; baseline acceptance performs no write, and successful owner empty publication is accepted. Unknown owner publication during active convergence invalidates pending generation/baseline state without adopting a baseline or clearing a release fence; outside convergence it preserves the prior state; only a proven matching owner publication can re-arm it. Publications during this healer's own convergence remain fenced until its terminal result. Release/native restore fences a known or selected target on same or unknown comparison and clears retry state; a proven different target is ignored. The boolean comparison helper is admission-style and is not used by release consumers. A postcommit unknown result accepts no success baseline or successful-heal credit and cannot undo a write already reported by the convergence writer. Failed or refused convergence retains an explicit pending retry, including catalog-success/cache-failure when no missing namespace remains on disk. Retries wait five minutes and obey rolling limits of six attempts and three successful heals per hour. Successful convergence accepts its result, while configuration deletion or a target change retires the old obligation. These are local cooperative recovery rules, not proof of installed Desktop or live-provider behavior. ## Codex-home diagnostics diff --git a/tests/codex-integration/codex-catalog-heal-integration.test.ts b/tests/codex-integration/codex-catalog-heal-integration.test.ts index 634bf70bacf..3cdc00c538c 100644 --- a/tests/codex-integration/codex-catalog-heal-integration.test.ts +++ b/tests/codex-integration/codex-catalog-heal-integration.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { armDetachedConfigBaseline, loadConfig, saveConfig } from "../../src/config"; import { captureCatalogAdmissionSnapshot, createCatalogConvergeRequest } from "../../src/codex/catalog-admission"; +import { compareCatalogHealPaths } from "../../src/codex/catalog/heal-observation"; import { clearGatherRoutedModelsInflight, resetCatalogRuntimeStateForTests } from "../../src/codex/catalog"; import { setBundledCatalogCacheForTests } from "../../src/codex/catalog/bundled"; import { catalogBackupPathFor, legacyCatalogBackupPath } from "../../src/codex/catalog/parsing"; @@ -159,6 +160,24 @@ test.each([undefined, Promise.resolve(true)])("an untyped guard returning %p can expect(existsSync(join(codexHome, "models_cache.json"))).toBe(false); }); +test("unknown expected-target identity prevents every catalog and cache write", async () => { + let expectedCatalogPath = catalogPath; + for (let i = 0; i < 20; i += 1) { + const next = join(codexHome, `expected-${i}.json`); + symlinkSync(expectedCatalogPath, next, "file"); expectedCatalogPath = next; + } + const gathered = await gatherCodexCatalogCandidate(captureCatalogAdmissionSnapshot(config())); + expect(gathered.kind).toBe("candidate"); + if (gathered.kind !== "candidate") throw new Error("Expected catalog candidate."); + expect(compareCatalogHealPaths(catalogPath, expectedCatalogPath)).toBe("unknown"); + expect(await commitCodexCatalogCandidate(gathered.candidate, 1_000, { expectedCatalogPath, beforeCommit: () => true })) + .toEqual({ kind: "stale", reason: "target-identity" }); + expect(readFileSync(catalogPath, "utf8")).toBe(nativeBytes); + expect(events).toEqual([]); + expect(existsSync(catalogBackupPathFor(catalogPath))).toBe(false); + expect(existsSync(join(codexHome, "models_cache.json"))).toBe(false); +}); + test("a journal target differing from the prepared config target prevents catalog and cache publication", async () => { const expectedCatalogPath = join(codexHome, "journal-catalog.json"); const cachePath = join(codexHome, "models_cache.json"); diff --git a/tests/codex-integration/codex-catalog-heal-reanchor.test.ts b/tests/codex-integration/codex-catalog-heal-reanchor.test.ts new file mode 100644 index 00000000000..64b2336fa25 --- /dev/null +++ b/tests/codex-integration/codex-catalog-heal-reanchor.test.ts @@ -0,0 +1,485 @@ +import { afterEach, expect, spyOn, test } from "bun:test"; +import * as fs from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { CATALOG_HEAL_MAX_PATH_READS, compareCatalogHealPaths, observeCatalogHealFile } from "../../src/codex/catalog/heal-observation"; +import { CATALOG_HEAL_RECHECK_MS, startCodexCatalogSelfHeal } from "../../src/codex/catalog-self-heal"; +import type { CatalogPublicationEvent } from "../../src/codex/catalog/publication-observer"; +import type { OcxConfig } from "../../src/types"; + +const roots: string[] = []; +const fixture = () => { + const root = fs.mkdtempSync(join(tmpdir(), "ocx-heal-reanchor-")); + roots.push(root); + return root; +}; +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +test("a leaf symlink retargeted before native resolution stays unknown on every platform", () => { + const root = fixture(), a = join(root, "a.json"), b = join(root, "b.json"), alias = join(root, "alias.json"); + fs.writeFileSync(a, "A"); + fs.writeFileSync(b, "B"); + fs.symlinkSync(a, alias, "file"); + const native = fs.realpathSync.native; + let swapped = false; + const spy = spyOn(fs.realpathSync, "native").mockImplementation((path, options) => { + if (!swapped) { + swapped = true; + fs.unlinkSync(alias); + fs.symlinkSync(b, alias, "file"); + } + return native(path, options); + }); + let relation; + try { relation = compareCatalogHealPaths(alias, a); } + finally { spy.mockRestore(); } + expect(swapped).toBe(true); + expect(native(alias)).toBe(native(b)); + expect(fs.readFileSync(alias, "utf8")).toBe("B"); + expect(relation).toBe("unknown"); +}); + +test.each([false, true])("a proven selected publication reanchors when the obsolete target is dangling (released=%s)", async released => { + const root = fixture(), oldPath = join(root, "old.json"), newPath = join(root, "new.json"); + const full = { models: [{ slug: "ark/a", description: "Routed via opencodex → ark/a (owner)." }] }; + fs.writeFileSync(oldPath, JSON.stringify(full)); + fs.writeFileSync(newPath, JSON.stringify(full)); + let selected = oldPath; + let listener: (event: CatalogPublicationEvent) => void = () => {}; + let converges = 0; + const config = { port: 10100, defaultProvider: "ark", providers: { + ark: { adapter: "openai-chat", baseUrl: "https://example.test/v1" }, + } } as OcxConfig; + const handle = startCodexCatalogSelfHeal({ deps: { + scheduleFn: () => ({ cancel() {} }), catalogPath: () => selected, + subscribe: fn => { listener = fn; return () => {}; }, + converge: async (_config, lifecycle) => { + converges++; + expect(lifecycle.expectedCatalogPath).toBe(newPath); + expect(lifecycle.beforeCommit()).toBe(true); + return { committed: false }; + }, + gates: { siblingOfLivePort: () => null, exiting: () => false, idle: () => true, + runtimeOwned: () => true, loadConfig: () => config, + ownsCodexHome: () => true, clientConnected: () => false, clientJournalOwner: () => false }, + log: { warn() {} }, + } }); + try { + if (released) listener({ kind: "native-released", path: oldPath }); + fs.renameSync(oldPath, join(root, "saved-old.json")); + fs.symlinkSync(join(root, "missing.json"), oldPath, "file"); + selected = newPath; + expect(compareCatalogHealPaths(oldPath, newPath)).toBe("unknown"); + expect(compareCatalogHealPaths(newPath, newPath)).toBe("same"); + listener({ kind: "published", path: newPath, intent: "refresh" }); + fs.writeFileSync(newPath, JSON.stringify({ models: [] })); + for (let i = 0; i < 3; i++) await handle.tickForTests(); + expect(converges).toBe(1); + } finally { handle.stop(); } +}); + +test.each(["entry", "parent"] as const)("native %s evidence is revalidated on every platform", kind => { + const root = fixture(), path = join(root, "catalog.json"); + fs.writeFileSync(path, "catalog"); + const lstat = fs.lstatSync, stat = fs.statSync; + let entries = 0, parents = 0, changed = false; + const drift = (value: T): T => { + changed = true; + return Object.assign(Object.create(Object.getPrototypeOf(value)), value, { ctimeNs: value.ctimeNs + 1n }); + }; + const spies = [ + spyOn(fs, "lstatSync").mockImplementation((value, options) => { + const result = lstat(value, options); + return kind === "entry" && ++entries >= 3 ? drift(result) : result; + }), + spyOn(fs, "statSync").mockImplementation((value, options) => { + const result = stat(value, options); + return kind === "parent" && ++parents >= 5 ? drift(result) : result; + }), + ]; + try { + expect(compareCatalogHealPaths(path, path)).toBe("unknown"); + expect(changed).toBe(true); + } finally { for (const spy of spies) spy.mockRestore(); } +}); + +test("native link observations and revalidation share the 64-call budget", () => { + const root = fixture(), path = join(root, "catalog.json"); + fs.writeFileSync(path, "catalog"); + let alias = path; + for (let i = 0; i < 9; i++) { + const next = join(root, `link-${i}.json`); + fs.symlinkSync(alias, next, "file"); + alias = next; + } + const lstat = fs.lstatSync, stat = fs.statSync, readlink = fs.readlinkSync, native = fs.realpathSync.native; + let reads = 0; + const spies = [ + spyOn(fs, "lstatSync").mockImplementation((...args) => { reads++; return lstat(...args); }), + spyOn(fs, "statSync").mockImplementation((...args) => { reads++; return stat(...args); }), + spyOn(fs, "readlinkSync").mockImplementation((...args) => { reads++; return readlink(...args); }), + spyOn(fs.realpathSync, "native").mockImplementation((...args) => { reads++; return native(...args); }), + ]; + try { + expect(compareCatalogHealPaths(alias, path)).toBe("unknown"); + expect(reads).toBe(CATALOG_HEAL_MAX_PATH_READS); + } finally { for (const spy of spies) spy.mockRestore(); } +}); + +test.each([false, true])("an unreadable selected publication preserves uncertainty and release fences (released=%s)", async released => { + const root = fixture(), oldPath = join(root, "old.json"), newPath = join(root, "new.json"); + const full = JSON.stringify({ models: [{ slug: "ark/a", description: "Routed via opencodex → ark/a (owner)." }] }); + fs.writeFileSync(oldPath, full); + fs.writeFileSync(newPath, "invalid catalog"); + let selected = oldPath; + let listener: (event: CatalogPublicationEvent) => void = () => {}; + let converges = 0; + const config = { port: 10100, defaultProvider: "ark", providers: { + ark: { adapter: "openai-chat", baseUrl: "https://example.test/v1" }, + } } as OcxConfig; + const handle = startCodexCatalogSelfHeal({ deps: { + scheduleFn: () => ({ cancel() {} }), catalogPath: () => selected, + subscribe: fn => { listener = fn; return () => {}; }, + converge: async () => { converges++; return { committed: false }; }, + gates: { siblingOfLivePort: () => null, exiting: () => false, idle: () => true, + runtimeOwned: () => true, loadConfig: () => config, + ownsCodexHome: () => true, clientConnected: () => false, clientJournalOwner: () => false }, + log: { warn() {} }, + } }); + try { + if (released) listener({ kind: "native-released", path: oldPath }); + fs.renameSync(oldPath, join(root, "saved-old.json")); + fs.symlinkSync(join(root, "missing.json"), oldPath, "file"); + selected = newPath; + expect(compareCatalogHealPaths(newPath, newPath)).toBe("same"); + listener({ kind: "published", path: newPath, intent: "refresh" }); + fs.writeFileSync(newPath, full); + await handle.tickForTests(); + fs.writeFileSync(newPath, JSON.stringify({ models: [] })); + await handle.tickForTests(); + expect(converges).toBe(0); + fs.writeFileSync(newPath, full); + listener({ kind: "published", path: newPath, intent: "refresh" }); + fs.writeFileSync(newPath, JSON.stringify({ models: [] })); + await handle.tickForTests(); + expect(converges).toBe(1); + } finally { handle.stop(); } +}); + +test.each(["corrupt", "unreadable"] as const)("a %s same-target publication preserves the previous loss baseline", async failure => { + const path = join(fixture(), "catalog.json"); + fs.writeFileSync(path, JSON.stringify({ models: [{ slug: "ark/a", description: "Routed via opencodex → ark/a (owner)." }] })); + let listener: (event: CatalogPublicationEvent) => void = () => {}; + let unreadable = false, converges = 0; + const config = { port: 10100, defaultProvider: "ark", providers: { + ark: { adapter: "openai-chat", baseUrl: "https://example.test/v1" }, + } } as OcxConfig; + const handle = startCodexCatalogSelfHeal({ deps: { + scheduleFn: () => ({ cancel() {} }), catalogPath: () => path, + observe: (selected, content) => unreadable ? null : observeCatalogHealFile(selected, content), + subscribe: fn => { listener = fn; return () => {}; }, + converge: async () => { converges++; return { committed: false }; }, + gates: { siblingOfLivePort: () => null, exiting: () => false, idle: () => true, + runtimeOwned: () => true, loadConfig: () => config, + ownsCodexHome: () => true, clientConnected: () => false, clientJournalOwner: () => false }, + log: { warn() {} }, + } }); + try { + if (failure === "corrupt") fs.writeFileSync(path, "invalid catalog"); + else unreadable = true; + listener({ kind: "published", path, intent: "refresh" }); + unreadable = false; + fs.writeFileSync(path, JSON.stringify({ models: [] })); + await handle.tickForTests(); + expect(converges).toBe(1); + } finally { handle.stop(); } +}); + +for (const publishedAlias of [false, true]) { + test.each([false, true])(`a publication cannot adopt a retargeted junction (publishedAlias=${publishedAlias}, released=%s)`, async released => { + const root = fixture(), a = join(root, "a"), b = join(root, "b"), alias = join(root, "alias"); + fs.mkdirSync(a); fs.mkdirSync(b); fs.symlinkSync(a, alias, "junction"); + const original = join(a, "catalog.json"), redirected = join(b, "catalog.json"), selectedAlias = join(alias, "catalog.json"); + const catalog = (names: string[]) => JSON.stringify({ models: names.map(ns => ({ + slug: `${ns}/a`, description: `Routed via opencodex → ${ns}/a (owner).`, + })) }); + fs.writeFileSync(original, catalog(["ark"])); + fs.writeFileSync(redirected, catalog(["ark", "tx"])); + let selected = original, armed = false, swapped = false, converges = 0; + let listener: (event: CatalogPublicationEvent) => void = () => {}; + const config = { port: 10100, defaultProvider: "ark", providers: { + ark: { adapter: "openai-chat", baseUrl: "https://example.test/v1" }, + tx: { adapter: "openai-chat", baseUrl: "https://example.test/v1" }, + } } as OcxConfig; + const handle = startCodexCatalogSelfHeal({ deps: { + scheduleFn: () => ({ cancel() {} }), catalogPath: () => selected, + subscribe: fn => { listener = fn; return () => {}; }, + observe: (path, content) => { + if (armed && !swapped) { + swapped = true; + fs.renameSync(alias, join(root, "saved-alias")); + fs.symlinkSync(b, alias, "junction"); + } + return observeCatalogHealFile(path, content); + }, + converge: async (_config, lifecycle) => { + converges++; + expect(lifecycle.beforeCommit()).toBe(true); + return { committed: false }; + }, + gates: { siblingOfLivePort: () => null, exiting: () => false, idle: () => true, + runtimeOwned: () => true, loadConfig: () => config, + ownsCodexHome: () => true, clientConnected: () => false, clientJournalOwner: () => false }, + log: { warn() {} }, + } }); + try { + if (released) listener({ kind: "native-released", path: original }); + selected = selectedAlias; + const published = publishedAlias ? selectedAlias : original; + expect(compareCatalogHealPaths(published, selected)).toBe("same"); + armed = true; + listener({ kind: "published", path: published, intent: "refresh" }); + expect(swapped).toBe(true); + expect(fs.realpathSync.native(selected)).toBe(fs.realpathSync.native(redirected)); + // Rejection retains the old baseline/release fence; a valid publication still re-arms. + if (!released) { + selected = original; + fs.writeFileSync(original, catalog([])); + await handle.tickForTests(); + expect(converges).toBe(1); + } + selected = selectedAlias; + fs.writeFileSync(redirected, catalog(["ark"])); + await handle.tickForTests(); + expect(converges).toBe(released ? 0 : 1); + selected = original; + if (released) { + fs.writeFileSync(original, catalog([])); + await handle.tickForTests(); + expect(converges).toBe(0); + } + fs.writeFileSync(original, catalog(["ark"])); + listener({ kind: "published", path: original, intent: "refresh" }); + fs.writeFileSync(original, catalog([])); + await handle.tickForTests(); + expect(converges).toBe(released ? 1 : 2); + } finally { handle.stop(); } + }); +} + +test.each([false, true])("publication identity precedes the initial comparisons (released=%s)", async released => { + const root = fixture(), a = join(root, "a"), b = join(root, "b"), alias = join(root, "alias"); + fs.mkdirSync(a); fs.mkdirSync(b); fs.symlinkSync(a, alias, "junction"); + const original = join(a, "catalog.json"), foreign = join(b, "catalog.json"), published = join(alias, "catalog.json"); + const catalog = (names: string[]) => JSON.stringify({ models: names.map(ns => ({ + slug: `${ns}/a`, description: `Routed via opencodex → ${ns}/a (owner).`, + })) }); + fs.writeFileSync(original, catalog(["ark"])); + fs.writeFileSync(foreign, catalog(["ark", "tx"])); + let listener: (event: CatalogPublicationEvent) => void = () => {}; + let converges = 0, authorized = false; + const config = { port: 10100, defaultProvider: "ark", providers: { + ark: { adapter: "openai-chat", baseUrl: "https://example.test/v1" }, + tx: { adapter: "openai-chat", baseUrl: "https://example.test/v1" }, + } } as OcxConfig; + const handle = startCodexCatalogSelfHeal({ deps: { + scheduleFn: () => ({ cancel() {} }), catalogPath: () => published, + subscribe: fn => { listener = fn; return () => {}; }, + converge: async (_config, lifecycle) => { + converges++; + authorized = lifecycle.beforeCommit(); + return { committed: false }; + }, + gates: { siblingOfLivePort: () => null, exiting: () => false, idle: () => true, + runtimeOwned: () => true, loadConfig: () => config, + ownsCodexHome: () => true, clientConnected: () => false, clientJournalOwner: () => false }, + log: { warn() {} }, + } }); + try { + if (released) listener({ kind: "native-released", path: original }); + const stat = fs.statSync; + let stats = 0, swapped = false; + const spy = spyOn(fs, "statSync").mockImplementation((path, options) => { + const result = stat(path, options); + // Two native comparisons use eight stat calls each. Retarget after the last + // returned A evidence, before the old handler began its independent capture. + // Capture-first must keep A evidence even when this schedule interrupts a comparison. + if (++stats === 16) { + fs.renameSync(alias, join(root, "saved-a")); + fs.symlinkSync(b, alias, "junction"); + swapped = true; + } + return result; + }); + try { listener({ kind: "published", path: published, intent: "refresh" }); } + finally { spy.mockRestore(); } + expect(swapped).toBe(true); + expect(fs.realpathSync.native(published)).toBe(fs.realpathSync.native(foreign)); + fs.writeFileSync(foreign, catalog(["ark"])); + await handle.tickForTests(); + expect(converges).toBe(0); + expect(authorized).toBe(false); + // The rejected publication preserves A's baseline and the original release fence. + fs.renameSync(alias, join(root, "saved-b")); + fs.renameSync(join(root, "saved-a"), alias); + fs.writeFileSync(original, catalog([])); + await handle.tickForTests(); + expect(converges).toBe(released ? 0 : 1); + expect(authorized).toBe(!released); + fs.writeFileSync(original, catalog(["ark"])); + listener({ kind: "published", path: published, intent: "refresh" }); + fs.writeFileSync(original, catalog([])); + await handle.tickForTests(); + expect(converges).toBe(released ? 1 : 2); + expect(authorized).toBe(true); + } finally { handle.stop(); } +}); + +test.each([false, true])("a published junction cannot supply foreign bytes during an A-B-A retarget (released=%s)", async released => { + const root = fixture(), a = join(root, "a"), b = join(root, "b"), alias = join(root, "alias"); + fs.mkdirSync(a); fs.mkdirSync(b); fs.symlinkSync(a, alias, "junction"); + const original = join(a, "catalog.json"), published = join(alias, "catalog.json"); + const catalog = (names: string[]) => JSON.stringify({ models: names.map(ns => ({ + slug: `${ns}/a`, description: `Routed via opencodex → ${ns}/a (owner).`, + })) }); + fs.writeFileSync(original, catalog(["ark"])); + fs.writeFileSync(join(b, "catalog.json"), catalog(["ark", "tx"])); + let armed = false, reads = 0, converges = 0; + let listener: (event: CatalogPublicationEvent) => void = () => {}; + const config = { port: 10100, defaultProvider: "ark", providers: { + ark: { adapter: "openai-chat", baseUrl: "https://example.test/v1" }, + tx: { adapter: "openai-chat", baseUrl: "https://example.test/v1" }, + } } as OcxConfig; + const handle = startCodexCatalogSelfHeal({ deps: { + scheduleFn: () => ({ cancel() {} }), catalogPath: () => published, + subscribe: fn => { listener = fn; return () => {}; }, + observe: (path, content) => { + if (!armed) return observeCatalogHealFile(path, content); + if (reads++ === 0) { + fs.renameSync(alias, join(root, "saved-a")); + fs.symlinkSync(b, alias, "junction"); + } + const result = observeCatalogHealFile(path, content); + if (content) { + fs.renameSync(alias, join(root, "saved-b")); + fs.renameSync(join(root, "saved-a"), alias); + armed = false; + } + return result; + }, + converge: async (_config, lifecycle) => { + converges++; + expect(lifecycle.beforeCommit()).toBe(true); + return { committed: false }; + }, + gates: { siblingOfLivePort: () => null, exiting: () => false, idle: () => true, + runtimeOwned: () => true, loadConfig: () => config, + ownsCodexHome: () => true, clientConnected: () => false, clientJournalOwner: () => false }, + log: { warn() {} }, + } }); + try { + if (released) listener({ kind: "native-released", path: original }); + armed = true; + listener({ kind: "published", path: published, intent: "refresh" }); + expect(reads).toBe(2); + expect(compareCatalogHealPaths(original, published)).toBe("same"); + await handle.tickForTests(); + expect(converges).toBe(0); + fs.writeFileSync(original, catalog([])); + await handle.tickForTests(); + expect(converges).toBe(released ? 0 : 1); + } finally { handle.stop(); } +}); + +test("publication entry drift preserves a pending retry and its five-minute backoff", async () => { + const root = fixture(), path = join(root, "catalog.json"); + const full = JSON.stringify({ models: [{ slug: "ark/a", description: "Routed via opencodex → ark/a (owner)." }] }); + fs.writeFileSync(path, full); + let now = 0, armed = false, converges = 0; + let listener: (event: CatalogPublicationEvent) => void = () => {}; + const config = { port: 10100, defaultProvider: "ark", providers: { + ark: { adapter: "openai-chat", baseUrl: "https://example.test/v1" }, + } } as OcxConfig; + const handle = startCodexCatalogSelfHeal({ deps: { + now: () => now, scheduleFn: () => ({ cancel() {} }), catalogPath: () => path, + subscribe: fn => { listener = fn; return () => {}; }, + observe: (selected, content) => { + const result = observeCatalogHealFile(selected, content); + if (armed && content) { + armed = false; + fs.renameSync(path, join(root, "saved.json")); + fs.writeFileSync(path, full); + } + return result; + }, + converge: async () => { converges++; return { committed: false }; }, + gates: { siblingOfLivePort: () => null, exiting: () => false, idle: () => true, + runtimeOwned: () => true, loadConfig: () => config, + ownsCodexHome: () => true, clientConnected: () => false, clientJournalOwner: () => false }, + log: { warn() {} }, + } }); + try { + fs.writeFileSync(path, JSON.stringify({ models: [] })); + await handle.tickForTests(); + expect(converges).toBe(1); + fs.writeFileSync(path, full); + armed = true; + listener({ kind: "published", path, intent: "refresh" }); + expect(armed).toBe(false); + now = CATALOG_HEAL_RECHECK_MS - 1; + await handle.tickForTests(); + expect(converges).toBe(1); + now++; + await handle.tickForTests(); + expect(converges).toBe(2); + } finally { handle.stop(); } +}); + +test.each([false, true])("an accepted publication retains the validated post-read selection (released=%s)", async released => { + const root = fixture(), a = join(root, "a"), b = join(root, "b"), alias = join(root, "alias"); + fs.mkdirSync(a); fs.mkdirSync(b); fs.symlinkSync(a, alias, "junction"); + const published = join(a, "catalog.json"), initialSelection = join(alias, "catalog.json"); + const full = JSON.stringify({ models: [{ slug: "ark/a", description: "Routed via opencodex → ark/a (owner)." }] }); + fs.writeFileSync(published, full); + fs.writeFileSync(join(b, "catalog.json"), full); + let selected = initialSelection, armed = false, swapped = false, converges = 0; + let listener: (event: CatalogPublicationEvent) => void = () => {}; + const config = { port: 10100, defaultProvider: "ark", providers: { + ark: { adapter: "openai-chat", baseUrl: "https://example.test/v1" }, + } } as OcxConfig; + const handle = startCodexCatalogSelfHeal({ deps: { + scheduleFn: () => ({ cancel() {} }), catalogPath: () => selected, + subscribe: fn => { listener = fn; return () => {}; }, + observe: (path, content) => { + if (armed && !swapped) { + swapped = true; + fs.renameSync(alias, join(root, "saved-alias")); + fs.symlinkSync(b, alias, "junction"); + selected = published; + } + return observeCatalogHealFile(path, content); + }, + converge: async (_config, lifecycle) => { + converges++; + expect(lifecycle.expectedCatalogPath).toBe(published); + expect(lifecycle.beforeCommit()).toBe(true); + return { committed: false }; + }, + gates: { siblingOfLivePort: () => null, exiting: () => false, idle: () => true, + runtimeOwned: () => true, loadConfig: () => config, + ownsCodexHome: () => true, clientConnected: () => false, clientJournalOwner: () => false }, + log: { warn() {} }, + } }); + try { + if (released) listener({ kind: "native-released", path: published }); + armed = true; + listener({ kind: "published", path: published, intent: "refresh" }); + expect(swapped).toBe(true); + expect(compareCatalogHealPaths(initialSelection, selected)).toBe("different"); + fs.writeFileSync(published, JSON.stringify({ models: [] })); + await handle.tickForTests(); + expect(converges).toBe(1); + } finally { handle.stop(); } +}); diff --git a/tests/codex-integration/codex-catalog-self-heal.test.ts b/tests/codex-integration/codex-catalog-self-heal.test.ts index 62100f28e21..9b518d22db4 100644 --- a/tests/codex-integration/codex-catalog-self-heal.test.ts +++ b/tests/codex-integration/codex-catalog-self-heal.test.ts @@ -1,13 +1,14 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { linkSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, truncateSync, writeFileSync } from "node:fs"; +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import * as fs from "node:fs"; +import { linkSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, truncateSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { CATALOG_HEAL_MAX_ATTEMPTS, CATALOG_HEAL_MAX_HEALS, CATALOG_HEAL_RECHECK_MS, CATALOG_HEAL_TICK_MS, CATALOG_HEAL_WINDOW_MS, startCodexCatalogSelfHeal, type CatalogSelfHealDeps, type CatalogSelfHealGates, type CatalogSelfHealHandle, } from "../../src/codex/catalog-self-heal"; -import { CATALOG_HEAL_MAX_BYTES, observeCatalogHealFile, selectCatalogHealPath } from "../../src/codex/catalog/heal-observation"; +import { CATALOG_HEAL_MAX_BYTES, CATALOG_HEAL_MAX_LINKS, CATALOG_HEAL_MAX_PATH_READS, compareCatalogHealPaths, inspectHealPath, observeCatalogHealFile, sameCatalogHealPath, selectCatalogHealPath } from "../../src/codex/catalog/heal-observation"; import type { CatalogPublicationEvent } from "../../src/codex/catalog/publication-observer"; import type { RawCatalog } from "../../src/codex/catalog/parsing"; import type { OcxConfig } from "../../src/types"; @@ -54,6 +55,7 @@ function harness(options: { path?: string; config?: Partial; gates?: scheduleFn: (fn, ms) => { timer = fn; delays.push(ms); return { cancel: () => { cancelled += 1; timer = null; } }; }, now: () => clock, catalogPath: () => path, + retainPath: () => () => true, // This harness supplies synthetic observations, including virtual paths. observe: (_path, content) => { if (content) reads += 1; return { signature: `v${version}`, catalog: content ? structuredClone(catalog) : null }; @@ -511,6 +513,552 @@ function aliasPaths(kind: "lexical" | "physical" | "case") { return { path, alias: kind === "case" ? path.toUpperCase() : `${home}/./catalog.json` }; } +async function withCatalogPlatform(platform: "win32" | "darwin", run: () => void | Promise) { + const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform")!; + Object.defineProperty(process, "platform", { configurable: true, value: platform }); + try { await run(); } + finally { Object.defineProperty(process, "platform", platformDescriptor); } +} + +async function withNativeFileAlias(path: string, alias: string, platform: "win32" | "darwin", run: () => void | Promise) { + const native = realpathSync.native, stat = fs.lstatSync; + // Model an OS filename alias, not a second directory entry. Linux need not expose it. + const aliasStat = spyOn(fs, "lstatSync").mockImplementation((input, options) => + input === alias ? stat(path, options) : stat(input, options)); + const aliasPath = spyOn(realpathSync, "native").mockImplementation(input => + native(input === alias ? path : input)); + try { await withCatalogPlatform(platform, run); } + finally { + aliasPath.mockRestore(); aliasStat.mockRestore(); + } +} + +describe("catalog path comparison", () => { + test.each(["CAFÉ-CATALOG.JSON", "cafe\u0301-catalog.json"])("Mac hardlink alias %s stays unknown without entry-name evidence", async leaf => { + const root = temporaryRoot(), path = join(root, "café-catalog.json"), alias = join(root, leaf); + writeFileSync(path, JSON.stringify(full)); + const sibling = join(temporaryRoot(), "catalog.json"); + linkSync(path, sibling); + await withNativeFileAlias(path, alias, "darwin", () => { + expect(compareCatalogHealPaths(path, alias)).toBe("unknown"); + expect(compareCatalogHealPaths(alias, path)).toBe("unknown"); + expect(compareCatalogHealPaths(path, path)).toBe("same"); + expect(compareCatalogHealPaths(path, sibling)).toBe("different"); + }); + }); + + test("Mac same-parent hardlink names are not guessed distinct or enumerated eagerly", async () => { + const { path } = aliasPaths("lexical"), sibling = join(dirname(path), "sibling.json"); + linkSync(path, sibling); + const readdir = spyOn(fs, "readdirSync").mockImplementation(() => { throw new Error("Unbounded listing"); }); + const opendir = spyOn(fs, "opendirSync").mockImplementation(() => { throw new Error("Eager Bun opendir"); }); + try { + await withCatalogPlatform("darwin", () => { + expect(compareCatalogHealPaths(path, sibling)).toBe("unknown"); + expect(compareCatalogHealPaths(sibling, path)).toBe("unknown"); + expect(readdir).not.toHaveBeenCalled(); + expect(opendir).not.toHaveBeenCalled(); + }); + } finally { readdir.mockRestore(); opendir.mockRestore(); } + }); + + test.each(["input", "parent"] as const)("Mac %s identity drift prevents a same result", async kind => { + const { path } = aliasPaths("lexical"); + linkSync(path, join(temporaryRoot(), "catalog.json")); + const lstat = fs.lstatSync, stat = fs.statSync; + let reads = 0; + const changed = (value: ReturnType) => Object.assign(Object.create(Object.getPrototypeOf(value)), value, + { ctimeNs: typeof value.ctimeNs === "bigint" ? value.ctimeNs + 1n : value.ctimeNs + 1 }); + const input = spyOn(fs, "lstatSync").mockImplementation((value, options) => { + const result = lstat(value, options); + return kind === "input" && value === path && ++reads >= 3 ? changed(result) : result; + }); + const parent = spyOn(fs, "statSync").mockImplementation((value, options) => { + const result = stat(value, options); + return kind === "parent" && ++reads >= 5 ? changed(result) : result; + }); + try { await withCatalogPlatform("darwin", () => { expect(compareCatalogHealPaths(path, path)).toBe("unknown"); }); } + finally { input.mockRestore(); parent.mockRestore(); } + }); + + test("a new hardlink during native resolution cannot evade Mac identity checks", async () => { + const { path } = aliasPaths("lexical"), sibling = join(temporaryRoot(), "catalog.json"); + const native = realpathSync.native; + let calls = 0; + const nativePath = spyOn(realpathSync, "native").mockImplementation(input => { + const result = native(input); + if (input === path && ++calls === 2) linkSync(path, sibling); + return result; + }); + try { await withCatalogPlatform("darwin", () => { expect(compareCatalogHealPaths(path, path)).toBe("unknown"); }); } + finally { nativePath.mockRestore(); } + }); + + test("path lookup stops at its I/O budget and reports unknown", async () => { + const { path } = aliasPaths("lexical"); + linkSync(path, join(temporaryRoot(), "catalog.json")); + let alias = path; + for (let i = 0; i < 20; i += 1) { + const next = join(dirname(path), `link-${i}.json`); + symlinkSync(alias, next, "file"); alias = next; + } + const lstat = fs.lstatSync, stat = fs.statSync, readlink = fs.readlinkSync, native = realpathSync.native; + let reads = 0; + const spies = [ + spyOn(fs, "lstatSync").mockImplementation((...args) => { reads++; return lstat(...args); }), + spyOn(fs, "statSync").mockImplementation((...args) => { reads++; return stat(...args); }), + spyOn(fs, "readlinkSync").mockImplementation((...args) => { reads++; return readlink(...args); }), + spyOn(realpathSync, "native").mockImplementation((...args) => { reads++; return native(...args); }), + ]; + try { + await withCatalogPlatform("darwin", () => { + expect(compareCatalogHealPaths(alias, path)).toBe("unknown"); + expect(reads).toBeLessThanOrEqual(CATALOG_HEAL_MAX_PATH_READS); + }); + } finally { for (const spy of spies) spy.mockRestore(); } + }); + + test.each(["win32", "darwin"] as const)("%s single-link native filename aliases remain equivalent", async platform => { + const { path } = aliasPaths("lexical"); + const alias = join(dirname(path), platform === "win32" ? "CATALO~9.JSO" : "CATALOG.JSON"); + expect(lstatSync(path).nlink).toBe(1); + await withNativeFileAlias(path, alias, platform, () => { + expect(sameCatalogHealPath(path, alias)).toBe(true); + expect(sameCatalogHealPath(alias, path)).toBe(true); + expect(sameCatalogHealPath(alias, alias)).toBe(true); + }); + }); + + test("Windows native filename aliases remain equivalent for multiply linked files", async () => { + const { path } = aliasPaths("lexical"); + const sibling = join(temporaryRoot(), "catalog.json"), alias = join(dirname(path), "CATALO~9.JSO"); + linkSync(path, sibling); + expect(lstatSync(path).nlink).toBe(2); + await withNativeFileAlias(path, alias, "win32", () => { + expect(sameCatalogHealPath(path, alias)).toBe(true); + expect(sameCatalogHealPath(alias, path)).toBe(true); + expect(sameCatalogHealPath(alias, sibling)).toBe(false); + expect(sameCatalogHealPath(sibling, alias)).toBe(false); + }); + }); + + test("hardlink entries remain distinct when native realpath reports their sibling", async () => { + const { path } = aliasPaths("lexical"); + const sibling = join(temporaryRoot(), "catalog.json"); + linkSync(path, sibling); + expect(lstatSync(sibling).ino).toBe(lstatSync(path).ino); + const native = realpathSync.native; + // macOS Bun can return the other hardlink's name for either opened file. + const resolvePath = spyOn(realpathSync, "native").mockImplementation(input => + input === path || input === sibling ? path : native(input)); + try { + await withCatalogPlatform("darwin", () => { + expect(sameCatalogHealPath(path, sibling)).toBe(false); + expect(sameCatalogHealPath(sibling, path)).toBe(false); + expect(sameCatalogHealPath(path, path)).toBe(true); + expect(sameCatalogHealPath(sibling, sibling)).toBe(true); + }); + } finally { resolvePath.mockRestore(); } + }); + + test("self comparison survives alternating native hardlink names", async () => { + const { path } = aliasPaths("lexical"); + const sibling = join(temporaryRoot(), "catalog.json"); + linkSync(path, sibling); + const native = realpathSync.native; + let calls = 0; + const resolvePath = spyOn(realpathSync, "native").mockImplementation(input => + input === path ? (++calls % 2 ? path : sibling) : native(input)); + try { await withCatalogPlatform("darwin", () => { expect(sameCatalogHealPath(path, path)).toBe(true); }); } + finally { resolvePath.mockRestore(); } + }); + + test("lexical and directory aliases are symmetric without merging distinct files", () => { + const { path, alias } = aliasPaths("physical"); + const distinct = join(temporaryRoot(), "catalog.json"); + writeFileSync(distinct, JSON.stringify(full)); + expect(sameCatalogHealPath(path, alias)).toBe(true); + expect(sameCatalogHealPath(alias, path)).toBe(true); + expect(sameCatalogHealPath(alias, alias)).toBe(true); + expect(sameCatalogHealPath(`${dirname(path)}/./catalog.json`, alias)).toBe(true); + expect(sameCatalogHealPath(alias, distinct)).toBe(false); + expect(sameCatalogHealPath(distinct, alias)).toBe(false); + }); + + test("relative leaf symlinks use their canonical parent and preserve hardlink names", () => { + const root = temporaryRoot(), home = join(root, "home"), inner = join(home, "inner"); + mkdirSync(inner, { recursive: true }); + const path = join(home, "catalog.json"), sibling = join(root, "catalog.json"); + writeFileSync(path, JSON.stringify(full)); + linkSync(path, sibling); + const parentAlias = join(root, "alias"); + symlinkSync(inner, parentAlias, process.platform === "win32" ? "junction" : "dir"); + const leaf = join(parentAlias, "link.json"); + symlinkSync("../catalog.json", join(inner, "link.json"), "file"); + const chain = join(inner, "chain.json"); + symlinkSync("link.json", chain, "file"); + const absolute = join(root, "absolute.json"); + symlinkSync(path, absolute, "file"); + expect(statSync(leaf).ino).toBe(lstatSync(path).ino); + expect(sameCatalogHealPath(leaf, path)).toBe(true); + expect(sameCatalogHealPath(path, leaf)).toBe(true); + expect(sameCatalogHealPath(chain, path)).toBe(true); + expect(sameCatalogHealPath(path, chain)).toBe(true); + expect(sameCatalogHealPath(absolute, path)).toBe(true); + expect(sameCatalogHealPath(path, absolute)).toBe(true); + expect(sameCatalogHealPath(leaf, sibling)).toBe(false); + expect(sameCatalogHealPath(sibling, leaf)).toBe(false); + }); + + test("a link target crossing a symlinked directory follows the platform's .. semantics", () => { + const root = temporaryRoot(), a = join(root, "a"), realHome = join(root, "b", "subdir"); + mkdirSync(realHome, { recursive: true }); + mkdirSync(a, { recursive: true }); + const real = join(root, "b", "catalog.json"), decoy = join(a, "catalog.json"); + writeFileSync(real, "MARKER-B-REAL"); + writeFileSync(decoy, "MARKER-A-DECOY"); + symlinkSync(realHome, join(a, "bridge"), process.platform === "win32" ? "junction" : "dir"); + const link = join(a, "link.json"); + symlinkSync("bridge/../catalog.json", link, "file"); + // Independent OS oracle: Windows normalizes ".." lexically across a reparse + // point (opens decoy); POSIX climbs the link's real parent (opens real). + const actual = process.platform === "win32" ? decoy : real; + const other = process.platform === "win32" ? real : decoy; + expect(readFileSync(link, "utf8")).toBe(readFileSync(actual, "utf8")); + expect(realpathSync.native(link)).toBe(realpathSync.native(actual)); + expect(compareCatalogHealPaths(link, actual)).toBe("same"); + expect(compareCatalogHealPaths(actual, link)).toBe("same"); + expect(compareCatalogHealPaths(link, other)).toBe("different"); + expect(compareCatalogHealPaths(other, link)).toBe("different"); + }); + + test("a link target crossing a missing directory follows the platform's crossing rules", () => { + const root = temporaryRoot(), a = join(root, "a"); + mkdirSync(a, { recursive: true }); + const present = join(a, "catalog.json"); + writeFileSync(present, "MARKER-A-PRESENT"); + const link = join(a, "link.json"); + symlinkSync("missing/../catalog.json", link, "file"); + if (process.platform === "win32") { + // The lexical ".." applies before the missing component is ever opened. + expect(readFileSync(link, "utf8")).toBe(readFileSync(present, "utf8")); + expect(realpathSync.native(link)).toBe(realpathSync.native(present)); + expect(compareCatalogHealPaths(link, present)).toBe("same"); + expect(compareCatalogHealPaths(present, link)).toBe("same"); + } else { + // POSIX refuses to cross a missing intermediate directory. + expect(() => readFileSync(link)).toThrow(); + expect(() => realpathSync.native(link)).toThrow(); + expect(compareCatalogHealPaths(link, present)).toBe("unknown"); + expect(compareCatalogHealPaths(present, link)).toBe("unknown"); + expect(inspectHealPath(link, read => read())).toEqual({ kind: "unavailable", hardlink: true }); + } + }); + + test.skipIf(process.platform === "win32")("a POSIX link target keeps literal backslashes inside names", () => { + // Windows has no such filenames; only POSIX must not split on "\\". + const root = temporaryRoot(), a = join(root, "a"); + const backslashDir = join(a, "we\\ird"); + mkdirSync(backslashDir, { recursive: true }); + const real = join(backslashDir, "catalog.json"), decoy = join(a, "catalog.json"); + writeFileSync(real, "MARKER-BACKSLASH"); + writeFileSync(decoy, "MARKER-A-DECOY"); + const link = join(a, "link.json"), dotdot = join(a, "dot.json"); + symlinkSync("we\\ird/catalog.json", link, "file"); + symlinkSync("we\\ird/../catalog.json", dotdot, "file"); + expect(realpathSync.native(link)).toBe(realpathSync.native(real)); + expect(readFileSync(link, "utf8")).toBe(readFileSync(real, "utf8")); + expect(compareCatalogHealPaths(link, real)).toBe("same"); + expect(compareCatalogHealPaths(link, decoy)).toBe("different"); + expect(realpathSync.native(dotdot)).toBe(realpathSync.native(decoy)); + expect(compareCatalogHealPaths(dotdot, decoy)).toBe("same"); + expect(compareCatalogHealPaths(dotdot, real)).toBe("different"); + }); + + test.skipIf(process.platform === "win32")("a POSIX link target crossing a file-pointing link refuses the traversal", () => { + // "bridge" resolves to a regular file: POSIX refuses "bridge/.." with ENOTDIR. + const root = temporaryRoot(), a = join(root, "a"); + mkdirSync(a, { recursive: true }); + const present = join(a, "catalog.json"), fileTarget = join(root, "file-target.json"); + writeFileSync(present, "MARKER-A-PRESENT"); + writeFileSync(fileTarget, "MARKER-FILE"); + symlinkSync(fileTarget, join(a, "bridge"), "file"); + const link = join(a, "link.json"); + symlinkSync("bridge/../catalog.json", link, "file"); + expect(() => readFileSync(link)).toThrow(); + expect(() => realpathSync.native(link)).toThrow(); + expect(compareCatalogHealPaths(link, present)).toBe("unknown"); + expect(compareCatalogHealPaths(present, link)).toBe("unknown"); + expect(inspectHealPath(link, read => read())).toEqual({ kind: "unavailable", hardlink: true }); + }); + + test("40-hop exhaustion stays uncertain independently of the comparator read budget", () => { + const root = temporaryRoot(), cycle = join(root, "cycle.json"); + symlinkSync("cycle.json", cycle, "file"); + let reads = 0; + const observed = inspectHealPath(cycle, read => { reads += 1; return read(); }); + expect(CATALOG_HEAL_MAX_LINKS).toBe(40); + expect(CATALOG_HEAL_MAX_PATH_READS).toBe(64); + expect(reads).toBe(CATALOG_HEAL_MAX_LINKS * 4); + expect(reads).toBeGreaterThan(CATALOG_HEAL_MAX_PATH_READS); + // This marker forces compareCatalogHealPaths' unknown branch, never lexical fallback. + expect(observed).toEqual({ kind: "unavailable", hardlink: true }); + }); + + test.each([ + [1, "initial leaf lstat", false], + [2, "symlink parent stat", true], + [3, "symlink readlink", true], + [4, "symlink parent realpath", true], + [5, "symlink target lstat", true], + [6, "symlink target realpath", true], + ] as const)("path failure at %s (%s) preserves observed-symlink uncertainty", (failAt, _stage, uncertain) => { + const root = temporaryRoot(), target = join(root, "target.json"), alias = join(root, "alias.json"); + writeFileSync(target, JSON.stringify(full)); + symlinkSync(target, alias, "file"); + let reads = 0; + const observed = inspectHealPath(alias, read => { + if (++reads === failAt) throw new Error("Fixture filesystem observation failed"); + return read(); + }); + expect(reads).toBe(failAt); + expect(observed).toEqual({ kind: "unavailable", hardlink: uncertain }); + }); + + test("initial unavailable leaves retain lexical equality while observed dangling links stay unknown", () => { + const root = temporaryRoot(), missing = join(root, "missing.json"); + const dangling = join(root, "dangling.json"), cycle = join(root, "cycle.json"); + symlinkSync("missing.json", dangling, "file"); + symlinkSync("cycle.json", cycle, "file"); + expect(compareCatalogHealPaths(missing, `${root}/./missing.json`)).toBe("same"); + expect(compareCatalogHealPaths(missing, join(root, "other-missing.json"))).toBe("different"); + expect(inspectHealPath(missing, read => read())).toEqual({ kind: "unavailable", hardlink: false }); + expect(compareCatalogHealPaths(dangling, dangling)).toBe("unknown"); + expect(compareCatalogHealPaths(dangling, missing)).toBe("unknown"); + expect(compareCatalogHealPaths(missing, dangling)).toBe("unknown"); + expect(sameCatalogHealPath(dangling, missing)).toBe(false); + expect(sameCatalogHealPath(missing, dangling)).toBe(false); + expect(compareCatalogHealPaths(cycle, cycle)).toBe("unknown"); + expect(sameCatalogHealPath(cycle, cycle)).toBe(false); + }); +}); + +for (const release of ["restore", "native-release"] as const) { + test(`observed-symlink lookup failure ${release} fences the known target while selection is unavailable`, async () => { + const root = temporaryRoot(), target = join(root, "target.json"), alias = join(root, "alias.json"); + writeFileSync(target, JSON.stringify(full)); + linkSync(target, join(root, "sibling.json")); + symlinkSync(target, alias, "file"); + const known = join(root, "missing-known.json"), h = harness({ path: known }); + const entered = deferred(), finish = deferred(); + let committed = false; + h.setHeal(async (_config, lifecycle) => { + entered.resolve(); await finish.promise; + committed = lifecycle.beforeCommit(); return { committed }; + }); + h.rewrite(empty); + const tick = h.handle.tickForTests(); + await entered.promise; + const originalReadlink = fs.readlinkSync; + const readlink = spyOn(fs, "readlinkSync").mockImplementation((path, options) => { + if (path === alias) throw new Error("Fixture readlink observation failed"); + return originalReadlink(path, options); + }); + try { + await withCatalogPlatform("darwin", async () => { + h.setPath(null); + const comparison = compareCatalogHealPaths(alias, known); + h.publish(release === "restore" ? { kind: "published", path: alias, intent: "restore" } + : { kind: "native-released", path: alias }); + h.setPath(known); + finish.resolve(); await tick; + expect(committed).toBe(false); + expect(comparison).toBe("unknown"); + }); + } finally { finish.resolve(); await tick; readlink.mockRestore(); } + }); +} + +test("unknown Mac target identity defers observation reuse and healing", async () => { + const { path } = aliasPaths("lexical"), alias = join(dirname(path), "CATALOG.JSON"); + linkSync(path, join(temporaryRoot(), "catalog.json")); + const h = harness({ path }); + h.rewrite(empty); + await withNativeFileAlias(path, alias, "darwin", async () => { + h.setPath(alias); + await h.handle.tickForTests(); + expect(h.reads()).toBe(1); + expect(h.converges).toHaveLength(0); + }); +}); + +test("unknown Mac precommit identity refuses a suspended heal", async () => { + const { path } = aliasPaths("lexical"), alias = join(dirname(path), "CATALOG.JSON"); + linkSync(path, join(temporaryRoot(), "catalog.json")); + const h = harness({ path }); + const entered = deferred(), finish = deferred(); + let committed = true; + h.setHeal(async (_config, lifecycle) => { + entered.resolve(); await finish.promise; + committed = lifecycle.beforeCommit(); return { committed }; + }); + h.rewrite(empty); + const tick = h.handle.tickForTests(); await entered.promise; + await withNativeFileAlias(path, alias, "darwin", async () => { + try { + h.setPath(alias); finish.resolve(); await tick; + expect(committed).toBe(false); + expect(h.handle.lastHeal()).toBeNull(); + } finally { finish.resolve(); await tick; } + }); +}); + +test("a precommit parent race refuses a suspended heal", async () => { + const { path } = aliasPaths("lexical"); + linkSync(path, join(temporaryRoot(), "catalog.json")); + const h = harness({ path }); + const entered = deferred(), finish = deferred(); + let committed = true; + h.setHeal(async (_config, lifecycle) => { + entered.resolve(); await finish.promise; + committed = lifecycle.beforeCommit(); return { committed }; + }); + h.rewrite(empty); + const tick = h.handle.tickForTests(); await entered.promise; + const stat = fs.statSync; + let reads = 0; + const parent = spyOn(fs, "statSync").mockImplementation((value, options) => { + const result = stat(value, options); + return ++reads >= 5 ? Object.assign(Object.create(Object.getPrototypeOf(result)), result, { ctimeNs: result.ctimeNs + 1n }) : result; + }); + try { + await withCatalogPlatform("darwin", async () => { + finish.resolve(); await tick; + expect(committed).toBe(false); + }); + } finally { finish.resolve(); await tick; parent.mockRestore(); } +}); + +test("a release whose lookup exceeds the I/O budget fences a suspended heal", async () => { + const { path } = aliasPaths("lexical"); + let alias = path; + for (let i = 0; i < 20; i += 1) { + const next = join(dirname(path), `release-${i}.json`); + symlinkSync(alias, next, "file"); alias = next; + } + const h = harness({ path }); + const entered = deferred(), finish = deferred(); + let committed = true; + h.setHeal(async (_config, lifecycle) => { + entered.resolve(); await finish.promise; + committed = lifecycle.beforeCommit(); return { committed }; + }); + h.rewrite(empty); + const tick = h.handle.tickForTests(); await entered.promise; + try { + expect(compareCatalogHealPaths(alias, path)).toBe("unknown"); + h.publish({ kind: "native-released", path: alias }); + finish.resolve(); await tick; + expect(committed).toBe(false); + } finally { finish.resolve(); await tick; } +}); + +for (const release of ["restore", "native-release"] as const) { + test(`unknown Mac ${release} fences; unknown owner publication cannot rearm`, async () => { + const { path } = aliasPaths("lexical"), alias = join(dirname(path), "CATALOG.JSON"); + linkSync(path, join(temporaryRoot(), "catalog.json")); + const h = harness({ path }); + const entered = deferred(), finish = deferred(); + let committed = true; + h.setHeal(async (_config, lifecycle) => { + entered.resolve(); await finish.promise; + committed = lifecycle.beforeCommit(); return { committed }; + }); + h.rewrite(empty); + const tick = h.handle.tickForTests(); await entered.promise; + await withNativeFileAlias(path, alias, "darwin", async () => { + try { + h.setPath(null); + h.publish(release === "restore" ? { kind: "published", path: alias, intent: "restore" } + : { kind: "native-released", path: alias }); + h.setPath(path); finish.resolve(); await tick; + expect(committed).toBe(false); + const reads = h.reads(); + h.publish({ kind: "published", path: alias, intent: "refresh" }); + expect(h.reads()).toBe(reads); + h.advance(CATALOG_HEAL_WINDOW_MS); await h.handle.tickForTests(); + expect(h.converges).toHaveLength(1); + h.rewrite(full); h.publish({ kind: "published", path, intent: "refresh" }); + h.rewrite(empty); await h.handle.tickForTests(); + expect(h.converges).toHaveLength(2); + expect(committed).toBe(true); + } finally { finish.resolve(); await tick; } + }); + }); +} + +test("unknown Mac owner publication invalidates pending work without accepting a baseline", async () => { + const { path } = aliasPaths("lexical"), alias = join(dirname(path), "CATALOG.JSON"); + linkSync(path, join(temporaryRoot(), "catalog.json")); + const h = harness({ path }); + const entered = deferred(), finish = deferred(); + let committed = true; + h.setHeal(async (_config, lifecycle) => { + entered.resolve(); await finish.promise; + committed = lifecycle.beforeCommit(); return { committed }; + }); + h.rewrite(empty); + const tick = h.handle.tickForTests(); await entered.promise; + await withNativeFileAlias(path, alias, "darwin", async () => { + try { + const reads = h.reads(); + h.publish({ kind: "published", path: alias, intent: "refresh" }); + expect(h.reads()).toBe(reads); + finish.resolve(); await tick; + expect(committed).toBe(false); + } finally { finish.resolve(); await tick; } + }); +}); + +for (const release of ["restore", "native-release"] as const) { + test(`native filename alias ${release} fences the known target while selection is unavailable`, async () => { + const { path } = aliasPaths("lexical"); + const alias = join(dirname(path), "CATALO~9.JSO"); + const h = harness({ path }); + const entered = deferred(), finish = deferred(); + let committed = false; + h.setHeal(async (_config, lifecycle) => { + entered.resolve(); await finish.promise; + committed = lifecycle.beforeCommit(); return { committed }; + }); + h.rewrite(empty); + const tick = h.handle.tickForTests(); + await entered.promise; + await withNativeFileAlias(path, alias, "win32", async () => { + try { + h.setPath(null); // Restore can remove the journal before its notification. + h.publish(release === "restore" ? { kind: "published", path: alias, intent: "restore" } + : { kind: "native-released", path: alias }); + h.setPath(path); + finish.resolve(); await tick; + expect(committed).toBe(false); + } finally { finish.resolve(); await tick; } + }); + }); +} + +test("native filename alias owner publication accepts the equivalent target", async () => { + const { path } = aliasPaths("lexical"); + const alias = join(dirname(path), "CATALO~9.JSO"); + const h = harness({ path }); + h.rewrite(empty); + await withNativeFileAlias(path, alias, "win32", async () => { + h.publish({ kind: "published", path: alias, intent: "refresh" }); + await h.handle.tickForTests(); + expect(h.converges).toHaveLength(0); + }); +}); + for (const kind of ["lexical", "physical", ...(process.platform === "win32" ? ["case" as const] : [])] as const) { for (const release of ["restore", "native-release"] as const) { test(`${kind} alias ${release} during suspended heal fences publication`, async () => { @@ -603,6 +1151,35 @@ test.each(["distinct", "hardlink"] as const)("%s restore target remains unrelate expect(committed).toBe(true); }); +test("sibling hardlink release cannot fence a heal when native realpath collapses entries", async () => { + const { path } = aliasPaths("lexical"); + const sibling = join(temporaryRoot(), "catalog.json"); + linkSync(path, sibling); + const h = harness({ path }); + const entered = deferred(), finish = deferred(); + let committed = false; + h.setHeal(async (_config, lifecycle) => { + entered.resolve(); await finish.promise; + committed = lifecycle.beforeCommit(); return { committed }; + }); + h.rewrite(empty); + const tick = h.handle.tickForTests(); + await entered.promise; + const native = realpathSync.native; + const resolvePath = spyOn(realpathSync, "native").mockImplementation(input => + input === path || input === sibling ? path : native(input)); + try { + await withCatalogPlatform("darwin", async () => { + h.publish({ kind: "native-released", path: sibling }); + finish.resolve(); await tick; + expect(committed).toBe(true); + }); + } finally { + finish.resolve(); await tick; + resolvePath.mockRestore(); + } +}); + for (const stage of ["Claude", "Desktop"] as const) { test(`startup loss during deferred ${stage} reconciliation is observed after a successful no-op sync`, async () => { const gate = createReadinessGate(); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index cead7ca3353..576bb2076dc 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,5 @@ { "workbuddy-quota.test.ts": "providers", "workbuddy-login.test.ts": "oauth", "workbuddy-adapter.test.ts": "adapters", "workbuddy-provider.test.ts": "providers", "link-tunnel-history.test.ts": "clients", "link-readiness.test.ts": "clients", "link-diagnostics.test.ts": "clients", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "responses-phase-inference.test.ts": "responses", + "codex-catalog-heal-reanchor.test.ts": "codex-integration", "tool-identity-hash.test.ts": "adapters", "server-combo-required-tool-contract.test.ts": "server", "responses-anthropic-compaction-prefix.test.ts": "responses", From 911ad9f423d3ee589260670aefdf4d16e2e87df0 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 27 Oct 2026 05:15:44 +0900 Subject: [PATCH 095/101] fix(live): keep sideband admission until the upstream closes after client cancellation (#7767) A live sideband join now keeps its turn admission until the upstream transport closes when the client cancels after the upstream dial has started; before the dial, cancellation still retires the turn at once. --- scripts/test-layout/layout-clients.json | 1 + src/server/index/serve-options.ts | 4 +- structure/runtime.md | 2 +- tests/fixtures/test-layout-expected.json | 1 + tests/server/server-live-cancel.test.ts | 212 +++++++++++++++++++++++ 5 files changed, 217 insertions(+), 3 deletions(-) create mode 100644 tests/server/server-live-cancel.test.ts diff --git a/scripts/test-layout/layout-clients.json b/scripts/test-layout/layout-clients.json index 9bf6607a9ab..5420987b6e6 100644 --- a/scripts/test-layout/layout-clients.json +++ b/scripts/test-layout/layout-clients.json @@ -2,6 +2,7 @@ "agent-clients.test.ts": "clients", "workbuddy-selector-recovery.test.ts": "clients", "server-live-close.test.ts": "server", + "server-live-cancel.test.ts": "server", "link-diagnostics.test.ts": "clients", "link-tunnel-history.test.ts": "clients", "link-readiness.test.ts": "clients", diff --git a/src/server/index/serve-options.ts b/src/server/index/serve-options.ts index 781e73c7441..8a89b16b610 100644 --- a/src/server/index/serve-options.ts +++ b/src/server/index/serve-options.ts @@ -1767,7 +1767,7 @@ export function createServeOptions(ctx: ServeOptionsContext) { if (audioController) registerTurn(audioController, turnAdmissionLease); const acquisition = audioController ? clearableDeadline(120_000, AbortSignal.any([req.signal, audioController.signal])) : undefined; - const detachClientAbort = turnAdmissionLease.bindAbortSignal(req.signal); + const detachClientAbort = turnAdmissionLease.bindAbortSignal(req.signal); // detached at the dial; then close/CLOSED owns release const releaseAcquisition = () => { detachClientAbort(); acquisition?.clear(); @@ -1820,6 +1820,7 @@ export function createServeOptions(ctx: ServeOptionsContext) { finally { finalizeLiveRequest(499, { closeReason: "client_cancel" }); } return withCors(formatErrorResponse(499, "client_closed_request", "Audio connection canceled"), req, policy); } + detachClientAbort(); const upstreamHandshake = await openLiveSidebandUpstream( resolved.upstreamWsUrl, resolved.headers, @@ -1888,7 +1889,6 @@ export function createServeOptions(ctx: ServeOptionsContext) { return withCors(formatErrorResponse(502, "upstream_error", "Audio WebSocket upgrade failed"), req, policy); } if (upgraded) { - detachClientAbort(); acquisition?.clear(); finalizeLiveRequest(101); return undefined as unknown as Response; diff --git a/structure/runtime.md b/structure/runtime.md index 35e50832d4e..3075c4720ec 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -428,7 +428,7 @@ Responses route normalization resolves provider summary defaults from the origin ## Live sideband handshake -`src/server/index/serve-options.ts` establishes the authorized upstream live sideband before accepting the client WebSocket upgrade, and `src/server/index/live-sideband.ts` implements the bounded upstream dial. `openLiveSidebandUpstream` bounds the handshake to ten seconds and retains at most 32 frames and 1 MiB of preamble within the frame limit. `src/server/ws-bridge.ts` defines the runtime handoff carrying captured frames or terminal state. Failed handshakes return 502/504 and client cancellation returns 499; exact upstream 404/410 status is unavailable from Bun's client WebSocket. Admission ownership lasts until upstream close/CLOSED, including failed upgrades and failed attachment. The ordinary Responses WebSocket exchange remains separate. +`src/server/index/serve-options.ts` establishes the authorized upstream live sideband before accepting the client WebSocket upgrade, and `src/server/index/live-sideband.ts` implements the bounded upstream dial. `openLiveSidebandUpstream` bounds the handshake to ten seconds and retains at most 32 frames and 1 MiB of preamble within the frame limit. `src/server/ws-bridge.ts` defines the runtime handoff carrying captured frames or terminal state. Failed handshakes return 502/504 and client cancellation returns 499; exact upstream 404/410 status is unavailable from Bun's client WebSocket. Admission ownership lasts until upstream close/CLOSED, including failed upgrades, failed attachment and a client that disconnects while the join is pending. Before the upstream dial, client cancellation retires the turn at once; from the dial on, only close/CLOSED releases it (`tests/server/server-live-cancel.test.ts`). The ordinary Responses WebSocket exchange remains separate. The relay is transparent in both directions, and that includes the close: a downstream client's close code and reason are carried to the upstream through `clientCloseForUpstream`, which only substitutes 1000 for a code no endpoint may send and truncates the reason to the 123-byte control-frame limit. This matters to the caller, because a Frameless v3 client reads upstream 1000 as the session completing and any other code as a transport loss to reconnect. Both pre-upgrade and attached teardown allow one second for graceful closure, then call Bun's `terminate()`. Admission releases only after close/CLOSED; missing or failing termination cannot release a live lease, and a missing method receives one close retry. `tests/server/server-live-close.test.ts` covers silent real TCP peers on Bun 1.4.4, graceful peers and unconfirmed closure. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 576bb2076dc..a361c078077 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1186,6 +1186,7 @@ "server-kiro-device-login.test.ts": "server", "server-kiro-oauth-401-replay.test.ts": "server", "server-kiro-refusal-e2e.test.ts": "server", "server-live-existing-call.test.ts": "server", "server-live-close.test.ts": "server", + "server-live-cancel.test.ts": "server", "server-live-frame-log.test.ts": "server", "server-live-realtime-fixtures.test.ts": "server", "server-live.test.ts": "server", "server-loopback-host-gate.test.ts": "server", "server-management-auth.test.ts": "server", diff --git a/tests/server/server-live-cancel.test.ts b/tests/server/server-live-cancel.test.ts new file mode 100644 index 00000000000..ffc3503e528 --- /dev/null +++ b/tests/server/server-live-cancel.test.ts @@ -0,0 +1,212 @@ +/** + * Client cancellation of a live-sideband join: the admission lease belongs to the upstream + * transport, so a caller that disconnects while the upstream handshake is pending must not + * release it before that transport has actually closed. Before any transport exists, the same + * cancellation must retire the turn at once. + */ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { existsSync, mkdirSync } from "node:fs"; +import { connect } from "node:net"; +import { join } from "node:path"; +import * as accountStore from "../../src/codex/account-store"; +import { saveConfig } from "../../src/config"; +import { startServer } from "../../src/server"; +import { activeRegistryMetrics } from "../../src/server/lifecycle"; +import type { OcxConfig } from "../../src/types"; +import { fakeChatGptJwt } from "../helpers/fake-chatgpt-jwt"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { ownedServiceHomeInspection } from "../helpers/owned-service-home-inspection"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const previousApiToken = process.env.OPENCODEX_API_AUTH_TOKEN; +const previousOpencodexHome = process.env.OPENCODEX_HOME; +const TEST_DIR = join(import.meta.dir, ".tmp-server-live-cancel-test"); +const DIRECT_CHATGPT_TOKEN = fakeChatGptJwt({ chatgpt_account_id: "acct-123" }); +let isolatedCodexHome: IsolatedCodexHome | null = null; + +beforeEach(() => { + if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + delete process.env.OPENCODEX_API_AUTH_TOKEN; + isolatedCodexHome = installIsolatedCodexHome("ocx-server-live-cancel-codex-"); +}); + +afterEach(() => { + if (previousApiToken === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN; + else process.env.OPENCODEX_API_AUTH_TOKEN = previousApiToken; + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + isolatedCodexHome?.restore(); + isolatedCodexHome = null; + if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); +}); + +/** A sideband upstream that stays CONNECTING and only reaches CLOSED when the test says so. */ +class PendingUpstreamSocket { + private readonly listeners = new Map void>>(); + readyState: number = WebSocket.CONNECTING; + closeCalls = 0; + private readonly closeRequested = Promise.withResolvers(); + readonly closeRequestedPromise = this.closeRequested.promise; + + addEventListener(type: string, listener: (event: { code?: number; reason?: string }) => void): void { + const bucket = this.listeners.get(type) ?? []; + bucket.push(listener); + this.listeners.set(type, bucket); + } + + removeEventListener(type: string, listener: (event: { code?: number; reason?: string }) => void): void { + this.listeners.set(type, (this.listeners.get(type) ?? []).filter(entry => entry !== listener)); + } + + close(): void { + this.closeCalls += 1; + this.readyState = WebSocket.CLOSING; + this.closeRequested.resolve(); + } + + finishClose(): void { + this.readyState = WebSocket.CLOSED; + for (const listener of this.listeners.get("close") ?? []) listener({ code: 1006, reason: "closed after cancellation" }); + } +} + +function forwardConfig(): OcxConfig { + return { + port: 0, + defaultProvider: "openai", + openaiProviderTierVersion: 2, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "direct", + }, + }, + } as OcxConfig; +} + +function withDeadline(promise: Promise, message: string, ms = 5_000): Promise { + let timer: ReturnType | undefined; + return Promise.race([ + promise, + new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(message)), ms); }), + ]).finally(() => clearTimeout(timer)); +} + +test("a client that cancels a pending sideband join keeps admission until the upstream closes", async () => { + saveConfig(forwardConfig()); + const upstream = new PendingUpstreamSocket(); + const dialed = Promise.withResolvers(); + const server = startServer(0, { + inspectNativeCodexOwnership: ownedServiceHomeInspection("server-live-cancel fixture"), + liveSidebandWebSocketFactory: () => { + dialed.resolve(); + return upstream as unknown as WebSocket; + }, + }); + const activeTurnsBefore = activeRegistryMetrics().activeTurns.active; + const client = connect(Number(server.port), "127.0.0.1"); + client.on("error", () => {}); + try { + await withDeadline(new Promise(resolve => client.once("connect", () => resolve())), "client never connected"); + client.write([ + "GET /v1/realtime?call_id=rtc_cancel_pending_join HTTP/1.1", + `Host: 127.0.0.1:${server.port}`, + "Upgrade: websocket", + "Connection: Upgrade", + "Sec-WebSocket-Version: 13", + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==", + `Authorization: Bearer ${DIRECT_CHATGPT_TOKEN}`, + "ChatGPT-Account-Id: acct-123", + "OpenAI-Alpha: quicksilver=v2", + "X-Session-Id: rts_cancel_pending_join", + "", "", + ].join("\r\n")); + await withDeadline(dialed.promise, "sideband join never dialed the upstream"); + expect(activeRegistryMetrics().activeTurns.active).toBe(activeTurnsBefore + 1); + + // The caller goes away while the upstream handshake is still pending. + client.destroy(); + await withDeadline(upstream.closeRequestedPromise, "cancellation never asked the upstream to close"); + // Give every abort listener and pending continuation a turn to run. + await Bun.sleep(0); + await Bun.sleep(0); + expect(upstream.readyState).toBe(WebSocket.CLOSING); + expect(activeRegistryMetrics().activeTurns.active).toBe(activeTurnsBefore + 1); + + upstream.finishClose(); + await Bun.sleep(0); + expect(activeRegistryMetrics().activeTurns.active).toBe(activeTurnsBefore); + upstream.finishClose(); + expect(activeRegistryMetrics().activeTurns.active).toBe(activeTurnsBefore); + } finally { + client.destroy(); + upstream.finishClose(); + await server.stop(true); + } +}, { timeout: 15_000 }); + +test("a client that cancels during credential resolution releases admission before any dial", async () => { + saveConfig({ + port: 0, + defaultProvider: "openai", + openaiProviderTierVersion: 2, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "pool", + }, + }, + codexAccounts: [{ id: "pool-cancel", email: "cancel@example.test", isMain: false, chatgptAccountId: "acct-cancel" }], + activeCodexAccountId: "pool-cancel", + codexMainAccountHardLock: false, + } as OcxConfig); + accountStore.saveCodexAccountCredential("pool-cancel", { + accessToken: "fixture-expired-access", refreshToken: "fixture-refresh", expiresAt: 0, chatgptAccountId: "acct-cancel", + }); + // Hold credential preparation open, as a slow token refresh would, without a caller signal. + const entered = Promise.withResolvers(); + const held = Promise.withResolvers(); + const tokenSpy = spyOn(accountStore, "getValidCodexToken").mockImplementation(async () => { + entered.resolve(); + return held.promise; + }); + let dials = 0; + const server = startServer(0, { + inspectNativeCodexOwnership: ownedServiceHomeInspection("server-live-cancel fixture"), + liveSidebandWebSocketFactory: () => { dials += 1; throw new Error("a cancelled join must not dial"); }, + }); + const activeTurnsBefore = activeRegistryMetrics().activeTurns.active; + const client = connect(Number(server.port), "127.0.0.1"); + client.on("error", () => {}); + try { + await withDeadline(new Promise(resolve => client.once("connect", () => resolve())), "client never connected"); + client.write([ + "GET /v1/realtime?model=gpt-live HTTP/1.1", + `Host: 127.0.0.1:${server.port}`, + "Upgrade: websocket", + "Connection: Upgrade", + "Sec-WebSocket-Version: 13", + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==", + "", "", + ].join("\r\n")); + await withDeadline(entered.promise, "credential resolution never started"); + expect(activeRegistryMetrics().activeTurns.active).toBe(activeTurnsBefore + 1); + + client.destroy(); + const deadline = Date.now() + 5_000; + while (activeRegistryMetrics().activeTurns.active !== activeTurnsBefore && Date.now() < deadline) await Bun.sleep(5); + expect(activeRegistryMetrics().activeTurns.active).toBe(activeTurnsBefore); + expect(dials).toBe(0); + } finally { + held.reject(new Error("fixture released")); + client.destroy(); + await server.stop(true); + tokenSpy.mockRestore(); + } +}, { timeout: 15_000 }); From 0f642c45a93d241bc05755a17a6fd0c6b02cceed Mon Sep 17 00:00:00 2001 From: Caledvwlch Date: Tue, 27 Oct 2026 05:47:02 +0900 Subject: [PATCH 096/101] fix(codex): preserve credit recovery retry hints and scoped cooldowns (#7622) * fix(codex): renew credit evidence for namespace-bound Direct accounts * fix(codex): recover credit retry advice without weakening account boundaries * fix(test): supply required port in native pool fixture * test(codex): isolate ACL setup and cold quota child work * test(codex): complete fetch and routing fixture types * fix(codex): retain the latest applicable credit retry hold --------- Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> --- .../docs/reference/codex-retry-diagnostics.md | 21 ++ scripts/test-layout/layout.json | 2 +- src/codex/auth-context.ts | 27 ++- src/codex/pool-credit-policy.ts | 36 ++- src/codex/quota-types.ts | 3 + src/codex/quota.ts | 17 +- src/providers/openai-sidecar-credit.ts | 4 +- structure/providers/openai-tiers.md | 3 + .../codex-credit-retry-after.test.ts | 224 ++++++++++++++++++ .../codex-credit-scoped-retry.test.ts | 150 ++++++++++++ .../main-quota-provenance.test.ts | 73 ++++++ tests/fixtures/test-layout-expected.json | 2 +- .../direct-credit-renewal-ingress.test.ts | 3 +- 13 files changed, 544 insertions(+), 21 deletions(-) create mode 100644 tests/codex-integration/codex-credit-retry-after.test.ts create mode 100644 tests/codex-integration/codex-credit-scoped-retry.test.ts diff --git a/docs-site/src/content/docs/reference/codex-retry-diagnostics.md b/docs-site/src/content/docs/reference/codex-retry-diagnostics.md index a4eb1ec9722..0a7112cab84 100644 --- a/docs-site/src/content/docs/reference/codex-retry-diagnostics.md +++ b/docs-site/src/content/docs/reference/codex-retry-diagnostics.md @@ -48,6 +48,27 @@ that the account pool routed to the main account, the earliest known time anothe pool account recovers, whichever comes first (never sooner than one minute). Resume the Goal after that time; routing then picks an account from fresh state. +## Stored-account credit metadata retries + +An explicitly selected stored account can reach its included usage limit while its last +credit balance observation is older than five minutes. If credit spending is enabled for +that account and the evidence is stale, missing or incomplete, its local 429 advises retrying +after the 300-second base metadata cadence (or at the included reset if sooner). A later known upstream +cooldown for the selected quota scope, including account-wide holds, still takes precedence; when +both apply, the later deadline wins. Unrelated +model-group holds are not imported. The original included-window reset remains attached to the +internal refusal as `resetAt`; the shorter hint is a metadata recheck opportunity, not a new quota reset. + +The request remains refused until fresh spendable credit evidence is available. This hint +does not perform a refresh, bypass metadata-query pacing, guarantee recovery, change account +selection, or enable credit spending. Direct account bindings retain their identity. Explicit +credit opt-out, an older cached null without provenance, a known spending veto or a finite empty +balance without unlimited entitlement retain the included-reset advice. A newly observed missing +or malformed credit summary retracts spending authority but permits the short metadata retry; +an explicit spending veto still wins. Unlimited credits with a zero balance require fresh evidence +just like other credits. Ordinary upstream 429 delays keep the existing parser ceiling of 24 hours +for numeric and HTTP-date Retry-After values. + ## Triage: `idle timeout waiting for SSE` Codex ends a stream that has sent no event for its `stream_idle_timeout_ms` diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 3dac671e9ac..53debea0645 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -94,7 +94,7 @@ "codex-credits-settings.test.ts": "codex-integration", "codex-credits-probes.test.ts": "codex-integration", "codex-credits-after-limit.test.ts": "codex-integration", - "codex-pool-credit-policy.test.ts": "codex-integration", + "codex-pool-credit-policy.test.ts": "codex-integration", "codex-credit-retry-after.test.ts": "codex-integration", "codex-credit-scoped-retry.test.ts": "codex-integration", "codex-sidecar-credit-policy.test.ts": "codex-integration", "codex-credits-after-limit-main.test.ts": "codex-integration", "service-runtime-preflight.test.ts": "service", "service-desktop-runtime-preflight.test.ts": "service", diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index 5afdd2230fd..d6a52b96ae3 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -1,4 +1,4 @@ -import { bindPoolCreditPolicy, poolCreditHoldResetAt, poolContextCreditHoldResetAt } from "./pool-credit-policy"; +import { bindPoolCreditPolicy, poolCreditHoldResetAt, poolContextCreditHoldResetAt, poolCreditRecoveryNeeded, poolContextCreditRecoveryNeeded, poolCreditRecoveryRetryAt } from "./pool-credit-policy"; import { hasSpendableCodexCredits } from "./quota-types"; import { nextQuotaQueryDelay } from "./quota-query-backoff"; import { noteMainAccountActivity } from "./main-account-external-usage"; @@ -548,17 +548,20 @@ export class CodexMainAccountHardLockError extends CodexAccountCooldownError { export class CodexPoolAccountCreditsOffError extends CodexAccountCooldownError { readonly resetAt: number; - constructor(accountId: string, resetAt: number) { - super(accountId, resetAt); + constructor(accountId: string, resetAt: number, creditRecovery = false, quotaScope?: CodexQuotaScope) { + // Keep the included reset; a metadata hint neither admits nor clears an upstream hold. + super(accountId, creditRecovery ? poolCreditRecoveryRetryAt(accountId, resetAt, quotaScope) : resetAt); this.name = "CodexPoolAccountCreditsOffError"; this.resetAt = resetAt; - this.message = "The selected Codex account reached its usage limit, and spending credits is off or no fresh spendable balance is available." + this.message = creditRecovery + ? "The selected Codex account reached its usage limit and needs a fresh credit observation. Retry later; spending remains blocked until fresh spendable credits are observed." + : "The selected Codex account reached its usage limit, and spending credits is off or no fresh spendable balance is available." + " Wait for the limit to reset, choose another account, or explicitly allow credits for this account in Codex Auth."; } } -function assertPoolAccountCredits(accountId: string, resetAt: number | undefined): void { - if (resetAt !== undefined) throw new CodexPoolAccountCreditsOffError(accountId, resetAt); +function assertPoolAccountCredits(accountId: string, resetAt: number | undefined, creditRecovery: boolean, quotaScope?: CodexQuotaScope): void { + if (resetAt !== undefined) throw new CodexPoolAccountCreditsOffError(accountId, resetAt, creditRecovery, quotaScope); } /** The main login may not spend credits and one of its usage windows is full (#6334). */ @@ -780,7 +783,7 @@ export function createCodexAuthDispatchGuard( return headers => { if (config.pausedCodexAccountIds?.includes(ctx.accountId)) throw new CodexPoolAuthenticationError("Selected Codex account is paused; resume it in account settings"); if (ctx.kind === "main-pool") { reserveGuard?.(headers); return; } - assertPoolAccountCredits(ctx.accountId, poolContextCreditHoldResetAt(ctx, config)); + assertPoolAccountCredits(ctx.accountId, poolContextCreditHoldResetAt(ctx, config), poolContextCreditRecoveryNeeded(ctx, config), ctx.quotaScope); reserveGuard?.(headers); }; } @@ -1569,7 +1572,7 @@ async function resolveCodexAuthContextUnannotated( try { assertCodexAccountValidationReady(accountId); if (configuredCodexMainDuplicate(config, accountId)) throw new CodexPoolAuthenticationError(CODEX_MAIN_DUPLICATE_ACTION); - assertPoolAccountCredits(accountId, poolCreditHoldResetAt(policy, accountId, config)); + assertPoolAccountCredits(accountId, poolCreditHoldResetAt(policy, accountId, config), poolCreditRecoveryNeeded(policy, accountId), quotaScope); } catch (cause) { // Nothing will reach upstream, so give the trial back instead of leaving the held account // unprobeable until the lease deadline lapses (#4701). @@ -1700,7 +1703,7 @@ async function resolveCodexAuthContextUnannotated( const token = await getValidCodexToken(accountId, { signal: options.signal }); assertCodexAccountValidationReady(accountId); if (configuredCodexMainDuplicate(config, accountId)) throw new CodexPoolAuthenticationError(CODEX_MAIN_DUPLICATE_ACTION); - assertPoolAccountCredits(accountId, poolCreditHoldResetAt(policy, accountId, config)); + assertPoolAccountCredits(accountId, poolCreditHoldResetAt(policy, accountId, config), poolCreditRecoveryNeeded(policy, accountId), quotaScope); return bindPoolCreditPolicy({ kind: "pool", accountId, @@ -1732,7 +1735,7 @@ async function resolveCodexAuthContextUnannotated( export function assertCodexAuthContextNotCooled(ctx: CodexAuthContext | undefined): void { if (ctx?.kind !== "pool" && ctx?.kind !== "main-pool") return; - if (ctx.kind === "pool") assertPoolAccountCredits(ctx.accountId, poolContextCreditHoldResetAt(ctx)); + if (ctx.kind === "pool") assertPoolAccountCredits(ctx.accountId, poolContextCreditHoldResetAt(ctx), poolContextCreditRecoveryNeeded(ctx), ctx.quotaScope); // A context holding the probe lease was deliberately admitted through the cooldown. if (ctx.probeLeaseId) return; const cooldown = getCodexQuotaHealthSnapshot(ctx.accountId, ctx.quotaScope); @@ -1748,7 +1751,7 @@ export function applyCodexAuthContextToProvider( ): OcxRuntimeProviderConfig { if (mode !== "pool" || (ctx.kind !== "pool" && ctx.kind !== "main-pool") || provider.authMode !== "forward") return provider; assertCodexAccountValidationReady(ctx.accountId); - if (ctx.kind === "pool") assertPoolAccountCredits(ctx.accountId, poolContextCreditHoldResetAt(ctx)); + if (ctx.kind === "pool") assertPoolAccountCredits(ctx.accountId, poolContextCreditHoldResetAt(ctx), poolContextCreditRecoveryNeeded(ctx), ctx.quotaScope); return { ...provider, _codexAccountOverride: { @@ -1803,7 +1806,7 @@ export function materializeCodexUpstreamAuth( } if (ctx.kind === "pool" || ctx.kind === "main-pool") { assertCodexAccountValidationReady(ctx.accountId); - if (ctx.kind === "pool") assertPoolAccountCredits(ctx.accountId, poolContextCreditHoldResetAt(ctx, options.config)); + if (ctx.kind === "pool") assertPoolAccountCredits(ctx.accountId, poolContextCreditHoldResetAt(ctx, options.config), poolContextCreditRecoveryNeeded(ctx, options.config), ctx.quotaScope); selected.set("authorization", `Bearer ${ctx.accessToken}`); selected.set("chatgpt-account-id", ctx.chatgptAccountId); if (ctx.kind === "main-pool") { diff --git a/src/codex/pool-credit-policy.ts b/src/codex/pool-credit-policy.ts index d1a69e1eadd..e67898626f3 100644 --- a/src/codex/pool-credit-policy.ts +++ b/src/codex/pool-credit-policy.ts @@ -1,8 +1,11 @@ import type { OcxConfig } from "../types"; -import { codexUsageLimitResetAt, isCodexAccountHeldForCredits } from "./account-credit-use"; +import { codexAccountUsesCreditsAfterLimit, codexUsageLimitResetAt, isCodexAccountHeldForCredits } from "./account-credit-use"; import { MAIN_CODEX_ACCOUNT_ID } from "./main-account"; import { isSelectableCodexPoolAccount } from "./account-id"; import { getAccountQuota } from "./quota"; +import { hasSpendableCodexCredits } from "./quota-types"; +import { getCodexAccountCooldownUntil, scopedHealthFor, type CodexQuotaScope } from "./routing/health-store"; +import { nextQuotaQueryDelay } from "./quota-query-backoff"; export type PoolCreditPolicyConfig = Readonly>; type PoolContext = { kind: "pool"; accountId: string }; @@ -65,3 +68,34 @@ export function poolContextCreditHoldResetAt( const catalog = override?.codexAccounts ? override : bound?.config ?? policy; return poolCreditHoldResetAt(policy, context.accountId, catalog, now); } + +/** Retry advice only: absent/uncertain evidence can be re-observed, never spent. */ +export function poolCreditRecoveryNeeded(policy: PoolCreditPolicyConfig, accountId: string, now = Date.now()): boolean { + if (!codexAccountUsesCreditsAfterLimit(policy, accountId)) return false; + const quota = getAccountQuota(accountId); + const credits = quota?.credits; + // Only parser-proven unavailable null is re-observable. Legacy null and known + // vetoes remain conservative; unlimited overrides balance, never an explicit veto. + if (credits === null) return quota?.creditObservationUnavailable === true; + if (credits?.allowed === false || credits?.overageLimitReached === true + || credits?.hasCredits === false || (credits?.unlimited !== true && typeof credits?.balance === "number" + && Number.isFinite(credits.balance) && credits.balance <= 0)) return false; + return !hasSpendableCodexCredits({ credits }, now); +} + +/** Use the same live policy/override as the context's admission check. */ +export function poolContextCreditRecoveryNeeded(context: PoolContext, override?: PoolCreditPolicyConfig): boolean { + const policy = override ?? policies.get(context)?.policy; + return !!policy && poolCreditRecoveryNeeded(policy, context.accountId); +} + +/** Retry advice observes both applicable holds without changing routing's health precedence. */ +export function poolCreditRecoveryRetryAt( + accountId: string, resetAt: number, quotaScope?: CodexQuotaScope, now = Date.now(), +): number { + const scopedUntil = quotaScope ? scopedHealthFor(accountId, quotaScope)?.cooldownUntil : undefined; + const liveScopedUntil = typeof scopedUntil === "number" && Number.isFinite(scopedUntil) && scopedUntil > now + ? scopedUntil : 0; + return Math.max(Math.min(resetAt, now + nextQuotaQueryDelay()), + getCodexAccountCooldownUntil(accountId, now) ?? 0, liveScopedUntil); +} diff --git a/src/codex/quota-types.ts b/src/codex/quota-types.ts index d3854172f8f..be1a0bb65c4 100644 --- a/src/codex/quota-types.ts +++ b/src/codex/quota-types.ts @@ -108,6 +108,9 @@ export type StoredAccountQuota = { resetCredits?: number; /** Spendable usage credits; resetCredits are separate, manually redeemed reset tickets. */ credits?: CodexSpendableCredits | null; + /** Only with null: provider evidence was unavailable, not a proven spending veto. + * Affects retry advice only; legacy null remains conservative. */ + creditObservationUnavailable?: true; /** Monthly usage came from an explicitly monthly PRIMARY, not supplementary tertiary, window. */ monthlyIsPrimaryWindow?: boolean; updatedAt: number; diff --git a/src/codex/quota.ts b/src/codex/quota.ts index ebcd30372aa..5eebba7782b 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -389,8 +389,11 @@ function mergeAccountQuota( const creditsOnly = snapshotHasCredits(quota) && !snapshotHasUsage(quota); // An explicit empty/invalid credits summary retracts the old balance. Partial headers // retain the original observation clock instead of extending its spending authority. - if (quota.credits !== undefined) next.credits = quota.credits; - else if (existing?.credits !== undefined) next.credits = existing.credits; + const creditSource = quota.credits !== undefined ? quota : existing; + if (creditSource?.credits !== undefined) next.credits = creditSource.credits; + if (creditSource?.credits === null && creditSource.creditObservationUnavailable === true) { + next.creditObservationUnavailable = true; + } if (creditsOnly) { if (existing?.weeklyPercent !== undefined) next.weeklyPercent = existing.weeklyPercent; @@ -680,6 +683,8 @@ export function updateAccountQuota( ...(existing?.customWindows !== undefined ? { customWindows: existing.customWindows } : {}), ...(existing?.resetCredits !== undefined ? { resetCredits: existing.resetCredits } : {}), ...(existing?.credits !== undefined ? { credits: existing.credits } : {}), + ...(existing?.credits === null && existing.creditObservationUnavailable === true + ? { creditObservationUnavailable: true as const } : {}), updatedAt: Date.now(), }; assignCarriedShort(quota, existing, quota.updatedAt); @@ -739,7 +744,10 @@ function readMainPolicyQuota(value: unknown): MainPolicyQuota | null { } if (quota.monthlyPercent !== undefined && raw.monthlyIsPrimaryWindow === true) quota.monthlyIsPrimaryWindow = true; // Retain the independent credit timestamp on restart; it still expires at its original deadline. - if (raw.credits === null) quota.credits = null; + if (raw.credits === null) { + quota.credits = null; + if (raw.creditObservationUnavailable === true) quota.creditObservationUnavailable = true; + } else if (raw.credits && typeof raw.credits === "object" && !Array.isArray(raw.credits)) { const stored = raw.credits as Record; if (typeof stored.observedAt === "number" && Number.isFinite(stored.observedAt) && stored.observedAt >= 0) { @@ -976,6 +984,9 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit; +let originalFetch: typeof fetch; +let networkCalls = 0; +const cfg = (): OcxConfig => ({ + port: 10100, defaultProvider: "openai", providers: {}, codexMainAccountHardLock: false, + codexAccounts: [{ id: ID, email: "fixture@example.test", plan: "plus", isMain: false }], + codexAccountNamespaces: { subAlias: ID }, creditCodexAccountIds: [ID], + activeCodexAccountId: ID, autoSwitchThreshold: 0, upstreamFailoverThreshold: 3, +}); +function observe(credits: NonNullable[1]>["credits"], resetAt = RESET) { + setAccountQuotaFromParsed(ID, { weeklyPercent: 100, weeklyResetAt: resetAt, ...(credits === undefined ? {} : { credits }) }); +} +const positive = (age = 360_000) => ({ hasCredits: true, balance: 5, allowed: true, observedAt: NOW - age }); +const resolve = (config = cfg()) => resolveCodexAuthContext(new Headers(), config, "direct", { accountId: ID, modelId: "gpt-5.5" }); +async function refusal(config = cfg()): Promise { + const error = await resolve(config).catch(e => e); + expect(error).toBeInstanceOf(CodexPoolAccountCreditsOffError); + expect(error.resetAt).toBe(RESET); + expect(isAccountNeedsReauth(ID)).toBe(false); + return error; +} +function expectRetry(error: CodexAccountCooldownError, seconds: number) { + const response = cooldownErrorResponse(error, Date.now()); + expect(response.status).toBe(429); + expect(response.headers.get("retry-after")).toBe(String(seconds)); +} +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; previousCodexHome = process.env.CODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-credit-retry-")); + process.env.OPENCODEX_HOME = home; process.env.CODEX_HOME = home; + clock = spyOn(Date, "now").mockReturnValue(NOW); + const ok = { success: true, exitCode: 0, timedOut: false, stdout: "", stderr: "" }; + setIcaclsRunnerForTests(() => ok); setAsyncIcaclsRunnerForTests(async () => ok); + clearAccountQuota(); clearCodexUpstreamHealth(); clearThreadAccountMap(); clearPoolRotationState(); clearAccountNeedsReauth(ID); + saveCodexAccountCredential(ID, { accessToken: "fixture-credit-access", refreshToken: "fixture-credit-refresh", + expiresAt: NOW + 3_600_000, chatgptAccountId: "fixture-credit-workspace" }); + originalFetch = globalThis.fetch; networkCalls = 0; + globalThis.fetch = Object.assign((): never => { networkCalls++; throw new Error("Unexpected network request"); }, + { preconnect: originalFetch.preconnect }); +}); +afterEach(async () => { + try { + expect(networkCalls).toBe(0); + clearAccountQuota(); clearCodexUpstreamHealth(); clearThreadAccountMap(); clearPoolRotationState(); clearAccountNeedsReauth(ID); + await flushConfigDirHardeningForTests(); + } finally { + globalThis.fetch = originalFetch; clock.mockRestore(); + setIcaclsRunnerForTests(null); setAsyncIcaclsRunnerForTests(null); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = previousCodexHome; + removeTreeWithRetry(home); + } +}); +for (const kind of ["stale", "missing", "partial", "future", "invalid-time"] as const) test(`opted-in exact Direct account gets a metadata retry for ${kind} evidence`, async () => { + observe(kind === "stale" ? positive() : kind === "future" ? positive(-1) + : kind === "invalid-time" ? { ...positive(), observedAt: NaN } + : kind === "partial" ? { observedAt: NOW } : undefined); + const before = structuredClone(getAccountQuota(ID)); + const error = await refusal(); expectRetry(error, 300); + expect(getAccountQuota(ID)).toEqual(before); + await expect(resolve()).rejects.toBeInstanceOf(CodexPoolAccountCreditsOffError); +}); +test("opt-out retains the actual six-day reset despite stale evidence", async () => { + observe(positive()); const config = cfg(); config.creditCodexAccountIds = []; + expectRetry(await refusal(config), 518_400); +}); +for (const age of [0, 360_000]) for (const [reason, veto] of [ + ["empty", { balance: 0 }], ["spend control", { allowed: false }], + ["overage", { overageLimitReached: true }], ["no credits", { hasCredits: false }], +] as const) test(`known ${reason} retains reset at credit age ${age}`, async () => { + observe({ ...positive(age), ...veto }); expectRetry(await refusal(), 518_400); +}); +test("explicit retraction retains the included reset", async () => { + observe(null); expectRetry(await refusal(), 518_400); +}); +test("spend-control-only revocation remains a reset-bound refusal", async () => { + observe(positive(0)); setAccountQuotaFromParsed(ID, parseUsageQuota({ spend_control: { reached: true } })); + expect(getAccountQuota(ID)?.credits).toBeNull(); expectRetry(await refusal(), 518_400); +}); +test("the included reset earlier than metadata cadence remains the hint", async () => { + observe(positive(), NOW + 30_000); + const error = await resolve().catch(e => e); + expect(error).toBeInstanceOf(CodexPoolAccountCreditsOffError); + expect(error.resetAt).toBe(NOW + 30_000); expectRetry(error, 30); +}); +test("TTL boundary admits at five minutes and refuses just beyond it", async () => { + observe(positive(300_000)); const context = await resolve(); expect(context.kind).toBe("pool"); + clock.mockReturnValue(NOW + 1); + expect(() => materializeCodexUpstreamAuth(new Headers(), context)).toThrow(CodexPoolAccountCreditsOffError); +}); +for (const boundary of ["materialize", "dispatch", "sidecar", "provider", "cooldown"] as const) test(`late ${boundary} refusal retains live consent and metadata hint`, async () => { + observe(positive(0)); const config = cfg(); const context = await resolve(config); + observe(positive()); + const run = () => boundary === "materialize" ? materializeCodexUpstreamAuth(new Headers(), context) + : boundary === "dispatch" ? createCodexAuthDispatchGuard(context, config, "gpt-5.5")!(new Headers()) + : boundary === "provider" ? applyCodexAuthContextToProvider({ adapter: "openai-responses", baseUrl: "https://example.test", authMode: "forward" }, context, "pool") + : boundary === "cooldown" ? assertCodexAuthContextNotCooled(context) + : createOpenAiSidecarCreditGuard(context, config)!(); + let error: unknown; try { run(); } catch (e) { error = e; } + expect(error).toBeInstanceOf(CodexPoolAccountCreditsOffError); expectRetry(error as CodexAccountCooldownError, 300); + config.creditCodexAccountIds = []; + try { run(); } catch (e) { error = e; } + expectRetry(error as CodexAccountCooldownError, 518_400); +}); +test("authoritative upstream 429 Retry-After is not capped", () => { + const error = new CodexAccountCooldownError(ID, NOW + 3_600_000, "retry-after"); + expectRetry(error, 3_600); +}); +test("a stored upstream 429 cooldown still dominates a metadata-only retry hint", async () => { + observe(positive()); + recordCodexUpstreamOutcome(cfg(), ID, 429, { retryAfter: "3600", now: NOW }); + expectRetry(await refusal(), 3_600); +}); + +test("materialization override wins over bound credit consent in both directions", async () => { + observe(positive(0)); const config = cfg(); const context = await resolve(config); observe(positive()); + const attempt = (allowed: boolean) => { + try { materializeCodexUpstreamAuth(new Headers(), context, { config: { creditCodexAccountIds: allowed ? [ID] : [] } }); } + catch (error) { expect(error).toBeInstanceOf(CodexPoolAccountCreditsOffError); return error as CodexPoolAccountCreditsOffError; } + throw new Error("expected credit admission refusal"); + }; + expectRetry(attempt(false), 518_400); + config.creditCodexAccountIds = []; + const error = attempt(true); expect(error.resetAt).toBe(RESET); expectRetry(error, 300); +}); + +test("unlimited zero balance admits while fresh and requests metadata after expiry", async () => { + observe({ ...positive(300_000), unlimited: true, balance: 0 }); + expect((await resolve()).kind).toBe("pool"); + clock.mockReturnValue(NOW + 1); + expectRetry(await refusal(), 300); +}); +for (const veto of [{ allowed: false }, { hasCredits: false }, { overageLimitReached: true }]) { + test(`unlimited zero balance preserves explicit veto ${JSON.stringify(veto)}`, async () => { + observe({ ...positive(), unlimited: true, balance: 0, ...veto }); + expectRetry(await refusal(), 518_400); + }); +} +const unavailablePayloads: Parameters[0][] = [ + { credits: {} }, { credits: null }, { credits: { balance: "invalid" } }, + { rate_limit: { allowed: false } }, + { credits: "invalid" } as unknown as Parameters[0], +]; +for (const payload of unavailablePayloads) test(`parser uncertainty stays refused with a short retry: ${JSON.stringify(payload)}`, async () => { + observe(positive(0)); + setAccountQuotaFromParsed(ID, parseUsageQuota(payload)); + expect(getAccountQuota(ID)?.credits).toBeNull(); + expectRetry(await refusal(), 300); + updateAccountQuota(ID, 100, RESET); + setAccountQuotaFromParsed(ID, { weeklyPercent: 100, weeklyResetAt: RESET }); + expectRetry(await refusal(), 300); + const config = cfg(); config.creditCodexAccountIds = []; + expectRetry(await refusal(config), 518_400); +}); +for (const payload of unavailablePayloads) for (const spend_control of [{ reached: true }, {}]) { + test(`spending veto dominates unavailable evidence: ${JSON.stringify({ ...payload, spend_control })}`, async () => { + observe(positive(0)); + setAccountQuotaFromParsed(ID, parseUsageQuota({ ...payload, spend_control })); + expectRetry(await refusal(), 518_400); + }); +} +test("replacement clears uncertainty provenance, without reviving credit authority", async () => { + observe(positive(0)); + const context = await resolve(); + const unknown = () => setAccountQuotaFromParsed(ID, parseUsageQuota({ credits: {} })); + unknown(); expectRetry(await refusal(), 300); + expect(() => materializeCodexUpstreamAuth(new Headers(), context)).toThrow(CodexPoolAccountCreditsOffError); + observe(null); expectRetry(await refusal(), 518_400); + unknown(); observe({ ...positive(), allowed: false }); expectRetry(await refusal(), 518_400); + unknown(); observe(positive(0)); expect((await resolve()).kind).toBe("pool"); + expect(getAccountQuota(ID)?.creditObservationUnavailable).toBeUndefined(); + unknown(); observe(positive()); expectRetry(await refusal(), 300); + expect(getAccountQuota(ID)?.creditObservationUnavailable).toBeUndefined(); + unknown(); setAccountQuotaFromParsed(ID, parseUsageQuota({ spend_control: { reached: true } })); + expectRetry(await refusal(), 518_400); + unknown(); expectRetry(await refusal(), 300); +}); +test("unavailable versus legacy null survives persistence and a fresh reader process", async () => { + observe(positive()); + setAccountQuotaFromParsed(ID, parseUsageQuota({ credits: {} })); + setAccountQuotaFromParsed("legacy-null", { credits: null }); + const path = join(home, "codex-quota-cache.json"); + for (let tries = 0; !existsSync(path) && tries < 100; tries++) await Bun.sleep(10); + expect(existsSync(path)).toBe(true); + const child = Bun.spawnSync([process.execPath, "-e", ` + import { getAccountQuota } from './src/codex/quota'; + import { poolCreditRecoveryNeeded } from './src/codex/pool-credit-policy'; + Date.now = () => ${NOW}; + const policy = {creditCodexAccountIds: ['${ID}', 'legacy-null']}; + console.log(JSON.stringify(['${ID}', 'legacy-null'].map(id => ({ + credits: getAccountQuota(id)?.credits, recovery: poolCreditRecoveryNeeded(policy, id) + })))); + `], { cwd: repoRoot(), env: process.env, stdout: "pipe", stderr: "pipe" }); + expect(child.exitCode).toBe(0); + expect(JSON.parse(child.stdout.toString())).toEqual([ + { credits: null, recovery: true }, { credits: null, recovery: false }, + ]); +}); diff --git a/tests/codex-integration/codex-credit-scoped-retry.test.ts b/tests/codex-integration/codex-credit-scoped-retry.test.ts new file mode 100644 index 00000000000..7c44f9a2713 --- /dev/null +++ b/tests/codex-integration/codex-credit-scoped-retry.test.ts @@ -0,0 +1,150 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { OcxConfig } from "../../src/types"; +import { + CodexPoolAccountCreditsOffError, cooldownErrorResponse, resolveCodexAuthContext, + materializeCodexUpstreamAuth, materializeCodexUpstreamAuthAsync, + createCodexAuthDispatchGuard, applyCodexAuthContextToProvider, assertCodexAuthContextNotCooled, +} from "../../src/codex/auth-context"; +import { createOpenAiSidecarCreditGuard } from "../../src/providers/openai-sidecar-credit"; +import { saveCodexAccountCredential } from "../../src/codex/account-store"; +import { clearAccountNeedsReauth, isAccountNeedsReauth } from "../../src/codex/account-runtime-state"; +import { clearAccountQuota, getAccountQuota, setAccountQuotaFromParsed } from "../../src/codex/quota"; +import { + clearCodexUpstreamHealth, clearThreadAccountMap, getCodexQuotaHealthSnapshot, recordCodexUpstreamOutcome, +} from "../../src/codex/routing"; +import { clearPoolRotationState } from "../../src/codex/pool-rotation"; +import { flushConfigDirHardeningForTests } from "../../src/config/paths"; +import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const ID = "scoped-credit-fixture"; +const NOW = 1_800_000_000_000; +const RESET = NOW + 6 * 86_400_000; +const MODEL = "gpt-5.5"; +let home: string; +let previousHome: string | undefined, previousCodexHome: string | undefined; +let clock: ReturnType; +let originalFetch: typeof fetch; +let networkCalls: number; +let expectedNetworkCalls: number; +const cfg = (): OcxConfig => ({ + port: 10100, defaultProvider: "openai", providers: {}, codexMainAccountHardLock: false, + codexAccounts: [{ id: ID, email: "fixture@example.test", plan: "plus", isMain: false }], + codexAccountNamespaces: { subAlias: ID }, creditCodexAccountIds: [ID], + activeCodexAccountId: ID, autoSwitchThreshold: 0, upstreamFailoverThreshold: 3, +}); +function observe(age: number) { + setAccountQuotaFromParsed(ID, { weeklyPercent: 100, weeklyResetAt: RESET, + credits: { hasCredits: true, balance: 5, allowed: true, observedAt: NOW - age } }); +} +const resolve = (config: OcxConfig) => resolveCodexAuthContext(new Headers(), config, "direct", { accountId: ID, modelId: MODEL }); +function state() { + return structuredClone({ quota: getAccountQuota(ID), + account: getCodexQuotaHealthSnapshot(ID, undefined), + shared: getCodexQuotaHealthSnapshot(ID, "shared"), + reserve: getCodexQuotaHealthSnapshot(ID, "reserve") }); +} +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; previousCodexHome = process.env.CODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-credit-scoped-retry-")); + process.env.OPENCODEX_HOME = home; process.env.CODEX_HOME = home; + clock = spyOn(Date, "now").mockReturnValue(NOW); + const ok = { success: true, exitCode: 0, timedOut: false, stdout: "", stderr: "" }; + setIcaclsRunnerForTests(() => ok); setAsyncIcaclsRunnerForTests(async () => ok); + clearAccountQuota(); clearCodexUpstreamHealth(); clearThreadAccountMap(); clearPoolRotationState(); clearAccountNeedsReauth(ID); + saveCodexAccountCredential(ID, { accessToken: "fixture-scoped-access", refreshToken: "fixture-scoped-refresh", + expiresAt: NOW + 3_600_000, chatgptAccountId: "fixture-scoped-workspace" }); + originalFetch = globalThis.fetch; networkCalls = 0; expectedNetworkCalls = 0; + globalThis.fetch = Object.assign((): never => { networkCalls++; throw new Error("Unexpected network request"); }, + { preconnect: originalFetch.preconnect }); +}); +afterEach(async () => { + try { + expect(networkCalls).toBe(expectedNetworkCalls); + clearAccountQuota(); clearCodexUpstreamHealth(); clearThreadAccountMap(); clearPoolRotationState(); clearAccountNeedsReauth(ID); + await flushConfigDirHardeningForTests(); + } finally { + globalThis.fetch = originalFetch; clock.mockRestore(); + setIcaclsRunnerForTests(null); setAsyncIcaclsRunnerForTests(null); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = previousCodexHome; + removeTreeWithRetry(home); + } +}); + +const cases = [ + { name: "shared reset-derived hold", modelId: MODEL, retry: 900 }, + { name: "independent Reserve hold", modelId: "gpt-reserve", retry: 300 }, + { name: "account-wide Retry-After", modelId: "gpt-reserve", retryAfter: "3600", retry: 3600 }, + { name: "expired shared hold", modelId: MODEL, age: 901_000, retry: 300 }, + { name: "shared hold recorded before a shorter account hold", modelId: MODEL, retryAfter: "600", combined: "before", retry: 900 }, + { name: "shared hold recorded after a shorter account hold", modelId: MODEL, retryAfter: "600", combined: "after", retry: 900 }, + { name: "oversized numeric Retry-After", modelId: MODEL, retryAfter: "99999999999999999999", retry: 86_400 }, + { name: "far-future Retry-After date", modelId: MODEL, retryAfter: "Fri, 31 Dec 9999 23:59:59 GMT", retry: 86_400 }, +] as const; +for (const boundary of ["resolve", "materialize", "materialize-async", "dispatch", "provider", "cooldown", "sidecar"] as const) { + for (const scenario of cases) test(`${boundary} credit refusal respects ${scenario.name}`, async () => { + const config = cfg(); + observe(0); + const context = boundary === "resolve" ? undefined : await resolve(config); + if (context) expect(context).toMatchObject({ kind: "pool", accountId: ID, quotaScope: "shared" }); + observe(360_000); + const recordedAt = NOW - ("age" in scenario ? scenario.age : 0); + const recordSharedHold = () => recordCodexUpstreamOutcome(config, ID, 429, { + modelId: MODEL, resetAt: NOW + 3_600_000, fixedAccount: true, now: NOW, + }); + if ("combined" in scenario && scenario.combined === "before") recordSharedHold(); + recordCodexUpstreamOutcome(config, ID, 429, { modelId: scenario.modelId, + resetAt: recordedAt + 3_600_000, fixedAccount: true, now: recordedAt, + ...("retryAfter" in scenario ? { retryAfter: scenario.retryAfter } : {}) }); + if ("combined" in scenario && scenario.combined === "after") recordSharedHold(); + const before = state(); + let error: unknown; + try { + if (boundary === "resolve") await resolve(config); + else if (boundary === "materialize") materializeCodexUpstreamAuth(new Headers(), context!); + else if (boundary === "materialize-async") await materializeCodexUpstreamAuthAsync(new Headers(), context!); + else if (boundary === "dispatch") createCodexAuthDispatchGuard(context!, config, MODEL)!(new Headers()); + else if (boundary === "provider") applyCodexAuthContextToProvider({ + adapter: "openai-responses", baseUrl: "https://example.test", authMode: "forward", + }, context!, "pool"); + else if (boundary === "cooldown") assertCodexAuthContextNotCooled(context!); + else createOpenAiSidecarCreditGuard(context!, config)!(); + } catch (cause) { error = cause; } + expect(error).toBeInstanceOf(CodexPoolAccountCreditsOffError); + const refusal = error as CodexPoolAccountCreditsOffError; + expect(refusal.accountId).toBe(ID); + expect(refusal.resetAt).toBe(RESET); + const response = cooldownErrorResponse(refusal, NOW); + expect(response.status).toBe(429); + expect(response.headers.get("retry-after")).toBe(String(scenario.retry)); + expect(state()).toEqual(before); + expect(isAccountNeedsReauth(ID)).toBe(false); + }); +} + +test("resolve retains shared scope when credits become stale during token preparation", async () => { + const config = cfg(); + observe(0); + saveCodexAccountCredential(ID, { accessToken: "fixture-expired-access", refreshToken: "fixture-scoped-refresh", + expiresAt: NOW - 1, chatgptAccountId: "fixture-scoped-workspace" }); + expectedNetworkCalls = 1; + globalThis.fetch = Object.assign(async (input: Parameters[0], init?: RequestInit) => { + networkCalls++; + expect(String(input)).toBe("https://auth.openai.com/oauth/token"); + expect(init?.method).toBe("POST"); + observe(360_000); + recordCodexUpstreamOutcome(config, ID, 429, { modelId: MODEL, + resetAt: NOW + 3_600_000, fixedAccount: true, now: NOW }); + return Response.json({ access_token: "fixture-refreshed-access", refresh_token: "fixture-refreshed-grant", expires_in: 3600 }); + }, { preconnect: originalFetch.preconnect }) as typeof fetch; + const error = await resolve(config).catch(cause => cause); + expect(error).toBeInstanceOf(CodexPoolAccountCreditsOffError); + expect(error.resetAt).toBe(RESET); + expect(cooldownErrorResponse(error, NOW).headers.get("retry-after")).toBe("900"); + expect(getCodexQuotaHealthSnapshot(ID, "shared")?.cooldownUntil).toBe(NOW + 900_000); + expect(isAccountNeedsReauth(ID)).toBe(false); +}); diff --git a/tests/codex-integration/main-quota-provenance.test.ts b/tests/codex-integration/main-quota-provenance.test.ts index d8cb4b4fd65..bb227bcd716 100644 --- a/tests/codex-integration/main-quota-provenance.test.ts +++ b/tests/codex-integration/main-quota-provenance.test.ts @@ -5,6 +5,8 @@ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { MAIN_CODEX_ACCOUNT_ID as MAIN } from "../../src/codex/account-id"; +import { isCodexAccountHeldForCredits } from "../../src/codex/account-credit-use"; +import { hasSpendableCodexCredits } from "../../src/codex/quota-types"; import { getMainAccountHardLockStatus } from "../../src/codex/main-account-hard-lock"; import { applyConfirmedMainCodexAccountTransition, @@ -341,6 +343,77 @@ describe("main policy quota durability and lifecycle", () => { }); }, COLD_SPAWN_WARMUP_HOOK_BUDGET_MS); + for (const unavailable of [true, false]) { + test(`persisted ${unavailable ? "unavailable" : "legacy"} null remains identity-bound and cannot authorize spending`, () => { + const writer = writerFor(); + const resetAt = Date.now() + 86_400_000; + setAccountQuotaFromParsed(MAIN, { weeklyPercent: 100, weeklyResetAt: resetAt, + credits: { hasCredits: true, balance: 5, observedAt: Date.now() } }, undefined, writer); + setAccountQuotaFromParsed(MAIN, unavailable ? parseUsageQuota({ credits: {} }) : { credits: null }, + undefined, writer); + // Partial headers carry the pair without supplying new spending evidence. + applyAccountQuotaFromUpstreamHeaders(MAIN, new Headers({ "x-codex-primary-used-percent": "100", + "x-codex-primary-reset-at": String(resetAt) }), + undefined, writer); + const quota = getMainPolicyQuota(); + expect(quota?.credits).toBeNull(); + expect(quota?.creditObservationUnavailable).toBe(unavailable ? true : undefined); + expect(hasSpendableCodexCredits(quota)).toBe(false); + expect(isCodexAccountHeldForCredits({ creditCodexAccountIds: [MAIN] }, MAIN, quota, "plus", Date.now())).toBe(true); + const persisted = JSON.parse(flushPersistence()); + expect(persisted.mainPolicyQuota).toEqual({ identityKey: writer.identityKey, quota }); + // Read the actual serializer output in a fresh process, without clearing or rewriting it. + const script = ` + ${QUOTA_PROVENANCE_IMPORT_PROLOGUE} + import { hasSpendableCodexCredits } from ${JSON.stringify(repoPath("src/codex/quota-types.ts"))}; + import { isCodexAccountHeldForCredits } from ${JSON.stringify(repoPath("src/codex/account-credit-use.ts"))}; + globalThis.fetch = () => { throw new Error("Unexpected network request"); }; + const before = getMainPolicyQuota(); + observeMainQuotaIdentity("fixture-main-b"); + const other = getMainPolicyQuota(); + observeMainQuotaIdentity("fixture-main-a"); + const policy = getMainPolicyQuota(); + console.log(JSON.stringify({ before, other, policy, + spendable: hasSpendableCodexCredits(policy), + held: isCodexAccountHeldForCredits({ creditCodexAccountIds: ["__main__"] }, "__main__", policy, "plus", Date.now()) })); + `; + const child = Bun.spawnSync({ + cmd: [process.execPath, "--eval", script], cwd: repoRoot(), env: process.env, + timeout: process.platform === "win32" ? SPAWN_BUDGET_MS - INTERNAL_DEADLINE_MS : 10_000, + }); + expect(child.exitCode).toBe(0); + expect(JSON.parse(child.stdout.toString())).toEqual({ before: null, other: null, policy: quota, + spendable: false, held: true }); + }, SPAWN_BUDGET_MS); + } + + test("stale main writers cannot replace unavailable evidence or attach it to a new identity", () => { + const oldA = writerFor(); + const unavailable = parseUsageQuota({ credits: {} }); + const positive = { hasCredits: true, balance: 5, observedAt: Date.now() }; + setAccountQuotaFromParsed(MAIN, unavailable, undefined, oldA); + const writerB = writerFor("fixture-main-b"); + expect(getMainPolicyQuota()).toBeNull(); + setAccountQuotaFromParsed(MAIN, { credits: positive }, undefined, writerB); + setAccountQuotaFromParsed(MAIN, unavailable, undefined, oldA); + expect(getMainPolicyQuota()?.credits).toEqual(positive); + expect(getMainPolicyQuota()?.creditObservationUnavailable).toBeUndefined(); + + const newA = writerFor(); + setAccountQuotaFromParsed(MAIN, { weeklyPercent: 100, weeklyResetAt: Date.now() + 86_400_000 }, undefined, newA); + setAccountQuotaFromParsed(MAIN, unavailable, undefined, newA); + const before = getMainPolicyQuota(); + expect(before).toMatchObject({ credits: null, creditObservationUnavailable: true }); + // Returning to A does not revive a writer captured before the A → B → A transition. + setAccountQuotaFromParsed(MAIN, { credits: positive }, undefined, oldA); + expect(isMainQuotaWriterLive(oldA)).toBe(false); + expect(getMainPolicyQuota()).toEqual(before); + expect(getAccountQuota(MAIN)?.credits).toBeNull(); + expect(hasSpendableCodexCredits(getMainPolicyQuota())).toBe(false); + expect(isCodexAccountHeldForCredits({ creditCodexAccountIds: [MAIN] }, MAIN, getMainPolicyQuota(), "plus", Date.now())).toBe(true); + expect(JSON.parse(flushPersistence()).mainPolicyQuota).toEqual({ identityKey: newA.identityKey, quota: before }); + }); + for (const resetAt of [undefined, 4_000_000_000]) { test(`restart beyond six hours retains ${resetAt ? "future-reset" : "missing-reset"} policy evidence only for observed A`, () => { const writer = writerFor(); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index a361c078077..a663d916769 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -178,7 +178,7 @@ "codex-config-preservation.test.ts": "codex-integration", "codex-credits.test.ts": "codex-integration", "codex-credits-settings.test.ts": "codex-integration", "codex-credits-probes.test.ts": "codex-integration", "codex-credits-after-limit.test.ts": "codex-integration", - "codex-pool-credit-policy.test.ts": "codex-integration", + "codex-pool-credit-policy.test.ts": "codex-integration", "codex-credit-retry-after.test.ts": "codex-integration", "codex-credit-scoped-retry.test.ts": "codex-integration", "codex-direct-credit-renewal.test.ts": "codex-integration", "codex-direct-renewal-revocation.test.ts": "codex-integration", "direct-credit-renewal-ingress.test.ts": "responses", "codex-sidecar-credit-policy.test.ts": "codex-integration", diff --git a/tests/responses/direct-credit-renewal-ingress.test.ts b/tests/responses/direct-credit-renewal-ingress.test.ts index c722ca9367b..3df9a1c9fe2 100644 --- a/tests/responses/direct-credit-renewal-ingress.test.ts +++ b/tests/responses/direct-credit-renewal-ingress.test.ts @@ -112,7 +112,7 @@ async function send() { body: JSON.stringify({ model: "subAlias/gpt-5.5", input: "fixture", stream: false, store: false }), }); const response = await handleResponses(request, config, { model: "", provider: "" }); - return { status: response.status, body: await response.json() }; + return { status: response.status, retryAfter: response.headers.get("retry-after"), body: await response.json() }; } test("same Direct namespace recovers after five-minute credit expiry without fallback or external refresh", async () => { @@ -123,6 +123,7 @@ test("same Direct namespace recovers after five-minute credit expiry without fal now += 360_001; const stale = await send(); expect(stale.status).toBe(429); + expect(stale.retryAfter).toBe("300"); expect(responseAccounts).toHaveLength(1); expect(metadataAccounts).toEqual([]); const noMain = () => { throw new Error("stored-account renewal must never access main"); }; From 9e48f782cbac4a666b09574cb57a21be391c8d5b Mon Sep 17 00:00:00 2001 From: Studio <80060894+StudioPlatforms@users.noreply.github.com> Date: Mon, 26 Oct 2026 23:11:43 +0200 Subject: [PATCH 097/101] feat(providers): add AI Stupid Level API-key preset (#7623) * feat(providers): add AI Stupid Level API-key preset Adds AI Stupid Level's Smart Router as an ordinary API-key preset: openai-chat at https://aistupidlevel.info/v1, live discovery from the key-gated GET /v1/models (which also validates the key), and the 15 routing modes seeded with auto-coding as the default. Each routing mode declares a 262,144-token window, 64,000 output tokens and text-only input, limits that hold for any model the router picks. Every inbound stays on Chat: the router's /v1/messages is a passthrough to Anthropic without routing. Preset counts are recounted to 106 total and 85 key-based in all 8 guides, all 8 quickstarts and structure/ops/docs-and-release.md. Co-Authored-By: Claude Opus 5.5 * fix(providers): keep every AI Stupid Level model text-only, pinned ids included The preset declared text-only input for its 15 routing modes only. A pinned id from live discovery, such as claude-sonnet-4-6, had no modality evidence, so an attached image was forwarded to the router, which answers 400 unsupported_content. Add a provider-wide inputModalities declaration, the modality counterpart of contextWindow and supportsVerbosity. The registry seeds it, and enrichProviderFromRegistry and routedProviderConfig backfill it for rows saved earlier. isModelVisionSidecarConsumer, requiresVisionPreprocessing, modelAcceptsImageInput and the routing capability evidence fall back to it when no per-model entry lists the id; a per-model entry still wins. The AI Stupid Level preset declares ["text"]. Regression tests: a pinned live id needs vision preprocessing, also for a row saved before the declaration, and an image sent to aistupidlevel/claude-sonnet-4-6 through the proxy never reaches the router. Co-Authored-By: Claude Opus 5.5 * fix(providers): read provider-wide modalities only from the routed provider Review follow-up on the provider-wide inputModalities declaration: - Declare inputModalities on DerivedKeyLoginProvider, and copy it in providerConfigFromKeyLoginProvider, so a dashboard key login saves it like a CLI provider add does. - Drop the name-only registry fallback from the routing capability evidence. It applied the preset's ["text"] to a same-named custom destination; the routed provider already carries the declaration, backfilled for the preset destination only. Regression test: a same-named custom destination keeps its own modality evidence and needs no vision preprocessing. Co-Authored-By: Claude Opus 5.5 * docs(providers): record the 2026-10-26 re-verification of the AI Stupid Level preset The contract (router docs, terms, key-gated GET /v1/models) was checked again on 2026-10-26: the 15 routing modes are unchanged. The pool of pinnable models has changed since 2026-10-23 (Kimi K2.7 Code is no longer listed), so the limits comment now calls 262,144 a conservative window rather than the current pool minimum. Co-Authored-By: Claude Opus 5.5 * fix(providers): keep provider-wide modalities registry-only Review follow-up: a row seeded from the AI Stupid Level preset kept inputModalities: ["text"] after its baseUrl was pointed elsewhere, so a custom image-capable endpoint would have had its images stripped. The declaration is now registry-only, like other destination-bound registry defaults: it is no longer seeded into saved config (CLI add or key login), and enrichProviderFromRegistry and routedProviderConfig fill it only while the provider points at the preset destination. This also drops the src/oauth/login-cli.ts change. The custom-destination test now covers a seeded row moved to another URL as well as a hand-written custom row. Co-Authored-By: Claude Opus 5.5 * fix(providers): apply provider-wide modalities at runtime only Review follow-up: enrichProviderFromRegistry copied the preset's inputModalities onto the provider object, and init and management writes persist that object, so a saved copy could still follow a row to another URL. Enrichment no longer copies it. registryInputModalitiesForDestination() returns the registry value only while the row points at the preset destination; vision eligibility applies it to its private copy, and routedProviderConfig applies it to the routed provider. Regression test: enrichment leaves the saved provider without the field, while the runtime classification still holds at the preset destination and not at another URL. Co-Authored-By: Claude Opus 5.5 * fix(providers): validate runtime modality overrides for AI Stupid Level Reject malformed provider-wide modalities at schema and management boundaries, repair tolerant loads to text, and preserve exact model declaration precedence. Cover destination-bound runtime backfill without persisting registry defaults; derive the active key-login test roster from the registry. Co-authored-by: Studio Platforms * fix(providers): retain text-only fallback for renamed presets --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> --- .../docs/fr/getting-started/quickstart.md | 2 +- .../src/content/docs/fr/guides/providers.md | 2 +- .../docs/getting-started/quickstart.md | 2 +- .../src/content/docs/guides/providers.md | 28 ++- .../docs/ja/getting-started/quickstart.md | 2 +- .../src/content/docs/ja/guides/providers.md | 2 +- .../docs/ko/getting-started/quickstart.md | 2 +- .../src/content/docs/ko/guides/providers.md | 2 +- .../docs/ru/getting-started/quickstart.md | 2 +- .../src/content/docs/ru/guides/providers.md | 2 +- .../docs/tr/getting-started/quickstart.md | 2 +- .../src/content/docs/tr/guides/providers.md | 2 +- .../docs/zh-cn/getting-started/quickstart.md | 2 +- .../content/docs/zh-cn/guides/providers.md | 2 +- .../docs/zh-tw/getting-started/quickstart.md | 2 +- .../content/docs/zh-tw/guides/providers.md | 2 +- scripts/test-layout/layout.json | 4 +- src/codex/catalog/gather-capture.ts | 1 + src/config/load-degrade.ts | 5 + src/config/provider-validation.ts | 8 + src/config/schema/leaf-validators.ts | 5 + src/providers/derive.ts | 18 ++ src/providers/model-rename-fields.ts | 1 + src/providers/registry/entries-extended.ts | 31 +++ src/providers/registry/model-ids.ts | 1 + src/providers/registry/model-seeds.ts | 21 ++ src/providers/registry/types.ts | 8 + src/providers/routed-config.ts | 10 +- src/routing/capability.ts | 10 +- src/server/auth-cors.ts | 5 +- src/types/provider.ts | 6 + src/vision/eligibility.ts | 20 +- src/vision/plan.ts | 4 +- structure/catalog.md | 2 +- structure/config.md | 2 +- structure/data-planes/images.md | 2 + structure/gui-and-management-api.md | 2 +- structure/ops/docs-and-release.md | 2 +- structure/providers-and-adapters.md | 2 +- structure/runtime.md | 2 +- tests/fixtures/test-layout-expected.json | 3 +- .../providers/aistupidlevel-provider.test.ts | 229 ++++++++++++++++++ .../provider-input-modalities.test.ts | 222 +++++++++++++++++ .../provider-registry-parity.test.ts | 13 +- .../vision/vision-text-only-predicate.test.ts | 7 + 45 files changed, 657 insertions(+), 47 deletions(-) create mode 100644 tests/providers/aistupidlevel-provider.test.ts create mode 100644 tests/providers/provider-input-modalities.test.ts diff --git a/docs-site/src/content/docs/fr/getting-started/quickstart.md b/docs-site/src/content/docs/fr/getting-started/quickstart.md index 1306a62d864..fa96c775b52 100644 --- a/docs-site/src/content/docs/fr/getting-started/quickstart.md +++ b/docs-site/src/content/docs/fr/getting-started/quickstart.md @@ -13,7 +13,7 @@ ocx init `ocx init` vous accompagne dans les étapes suivantes : -1. **Choix d’un fournisseur** — sélectionnez l’un des 105 préréglages intégrés au registre, ou `custom` pour saisir une +1. **Choix d’un fournisseur** — sélectionnez l’un des 106 préréglages intégrés au registre, ou `custom` pour saisir une URL de base et un adaptateur. 2. **Clé API** — collez une clé ou référencez une variable d’environnement telle que `${ANTHROPIC_API_KEY}`. 3. **Modèle par défaut** — pour les fournisseurs clés, locaux et personnalisés, acceptez le préréglage ou saisissez un identifiant de modèle. diff --git a/docs-site/src/content/docs/fr/guides/providers.md b/docs-site/src/content/docs/fr/guides/providers.md index 5fc71d2fbb9..59b7a72c46b 100644 --- a/docs-site/src/content/docs/fr/guides/providers.md +++ b/docs-site/src/content/docs/fr/guides/providers.md @@ -309,7 +309,7 @@ existante n'est pas concernée. ## 3. Catalogue des clés API -opencodex fournit 105 préréglages intégrés : 84 à clé, 17 OAuth, trois locaux et un préréglage par défaut de +opencodex fournit 106 préréglages intégrés : 85 à clé, 17 OAuth, trois locaux et un préréglage par défaut de transfert ChatGPT. Dans le tableau de bord, le sélecteur **Ajouter un fournisseur** ouvre le tableau de bord du fournisseur à clé, valide la clé et l'enregistre ; la validation dépend du fournisseur. Parmi les entrées notables : diff --git a/docs-site/src/content/docs/getting-started/quickstart.md b/docs-site/src/content/docs/getting-started/quickstart.md index 92e0ed79459..ed39e7005e5 100644 --- a/docs-site/src/content/docs/getting-started/quickstart.md +++ b/docs-site/src/content/docs/getting-started/quickstart.md @@ -18,7 +18,7 @@ ocx init `ocx init` walks you through: -1. **Pick a provider** — choose one of the 105 built-in registry presets or `custom` to type a base +1. **Pick a provider** — choose one of the 106 built-in registry presets or `custom` to type a base URL and adapter. 2. **API key** — paste a key, or reference an environment variable like `${ANTHROPIC_API_KEY}`. 3. **Default model** — for key, local, and custom providers, accept the preset or enter a model id. diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 17b60809537..0238f96993b 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -694,7 +694,7 @@ On upgrade, startup adds Pro to a saved model list only when it exactly matches ## 3. API-key catalog -opencodex ships 105 built-in presets: 84 key-based, 17 OAuth, three local, and one default +opencodex ships 106 built-in presets: 85 key-based, 17 OAuth, three local, and one default ChatGPT-forward preset. The dashboard's **Add provider** picker opens a key provider's dashboard, validates the key, and stores it; validation is provider-specific. Notable entries: @@ -786,6 +786,7 @@ region-pinned EU routes, is at [opper.ai/models](https://opper.ai/models). Opper | Opper | `https://api.opper.ai/v3/compat` | | TokenLab | `https://api.tokenlab.sh/v1` | | Modelflare | `https://modelflare.dev/v1` | +| AI Stupid Level | `https://aistupidlevel.info/v1` | | OpenGateway | `https://apis.opengateway.ai/v1` | | GitLab Duo | `https://cloud.gitlab.com/ai/v1/proxy/openai/v1` | | Cloudflare AI Gateway | `https://gateway.ai.cloudflare.com/v1/{account-id}/{gateway}/anthropic` | @@ -880,6 +881,31 @@ can call only that group's models, so use a Smart key for the full seeded set, o `deepseek-v4.1-flash` is listed upstream but currently answers 503, so the preset leaves it out. Upstream latency varies from a few seconds to about 30 seconds, with occasional transient 503s. +**AI Stupid Level** is a Smart Router at `https://aistupidlevel.info/v1`, operated by S.C. Studio +Platforms S.R.L. Create a router key at [aistupidlevel.info/router/keys](https://aistupidlevel.info/router/keys) +and add your own OpenAI, Anthropic, Google, DeepSeek, Kimi or GLM keys to the same account. Then select +**AI Stupid Level** in `ocx init` or the dashboard's **Add provider** picker, or run +`ocx provider add aistupidlevel`, and give it the router key directly or as the reference +`${AISTUPIDLEVEL_API_KEY}`. The router calls each model with your own provider key, so the provider +bills you directly and AI Stupid Level adds no markup ([terms](https://aistupidlevel.info/terms#router)). +The preset uses Chat Completions for every client, Claude Code included, and discovers models from the +authenticated `GET /v1/models`. Its seeded models are the +[routing modes](https://aistupidlevel.info/router/docs#strategies): `auto-coding` (the default) sends +each request to the model with the best measured score on AI Stupid Level's coding benchmark, +`auto-tooling` and `auto-reasoning` do the same for tool use and reasoning, and `auto-value-coding` or +`auto-fastest-quality` pick the best-value or fastest model within 5 points of the best. A real model id +from the live list, such as `aistupidlevel/claude-opus-5-5`, pins one model, and the `X-AISM-Model` +response header names the model that answered. Routing modes declare a 262,144-token window and 64,000 +output tokens, which hold for any model the router picks. The router accepts text only, so for a +routing mode or pinned model at the preset destination the proxy hands images to the vision sidecar instead of sending them. Its Anthropic +Messages endpoint is a passthrough to Anthropic without routing, so the preset does not use it. + +The provider editor also accepts an optional `inputModalities` fallback: a nonempty array containing +`text`, `image`, `audio`, or `video`. Exact per-model declarations take precedence. Invalid values +are rejected on save; a malformed value in a hand-edited config loads as `["text"]` with a warning. +The preset's text-only fallback is applied at runtime, including when saved under another provider +name at the same URL and Chat adapter. It does not follow a custom URL or another adapter. + The MiniMax and MiniMax (CN) provider cards can also show Coding Plan quota when the configured key has an active plan. The dashboard reads the plan's 5-hour window and, when present, weekly window; these are display observations and do not change model routing. diff --git a/docs-site/src/content/docs/ja/getting-started/quickstart.md b/docs-site/src/content/docs/ja/getting-started/quickstart.md index ed820bab874..216e3891dac 100644 --- a/docs-site/src/content/docs/ja/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ja/getting-started/quickstart.md @@ -18,7 +18,7 @@ ocx init `ocx init` では次の手順を説明します。 -1. **プロバイダーを選択してください** — 105 個の組み込みレジストリプリセットのいずれか、または `custom` を選択してベース URL とアダプターを入力します。 +1. **プロバイダーを選択してください** — 106 個の組み込みレジストリプリセットのいずれか、または `custom` を選択してベース URL とアダプターを入力します。 2. **API キー** — キーを貼り付けるか、`${ANTHROPIC_API_KEY}` のような環境変数を参照します。 3. **デフォルト モデル** — キー、ローカル、カスタム プロバイダーの場合は、プリセットを受け入れるか、モデル ID を入力します。 4. **プロキシ ポート** — デフォルトは `10100` です。 diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index b2d71808ef0..39fec41d0ca 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -208,7 +208,7 @@ Kiro のログインには Kiro CLI が必要です。Unix では `curl -fsSL ht ## 3. API キーカタログ -opencodex には組み込みプリセットが 105 個含まれています。キー方式 84、OAuth 17、ローカル 3、 +opencodex には組み込みプリセットが 106 個含まれています。キー方式 85、OAuth 17、ローカル 3、 デフォルト ChatGPT 転送プリセット 1 です。ダッシュボードの **Add provider** ピッカーはキー発行ページを開き、 入力したキーを検証した後保存します(検証はプロバイダー固有です)。主な項目は以下のとおりです: diff --git a/docs-site/src/content/docs/ko/getting-started/quickstart.md b/docs-site/src/content/docs/ko/getting-started/quickstart.md index d92ce979391..77cc5a35d1e 100644 --- a/docs-site/src/content/docs/ko/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ko/getting-started/quickstart.md @@ -18,7 +18,7 @@ ocx init `ocx init`은 다음 과정을 안내합니다: -1. **프로바이더 선택** — 내장 레지스트리 프리셋 105개 중 하나를 고르거나 `custom`을 선택해 base URL과 adapter를 직접 입력합니다. +1. **프로바이더 선택** — 내장 레지스트리 프리셋 106개 중 하나를 고르거나 `custom`을 선택해 base URL과 adapter를 직접 입력합니다. 2. **API 키** — 키를 붙여넣거나 `${ANTHROPIC_API_KEY}` 같은 환경 변수를 참조합니다. 3. **기본 모델** — 키, 로컬, custom 프로바이더에서는 프리셋을 그대로 쓰거나 모델 ID를 직접 입력합니다. 4. **프록시 포트** — 기본값은 `10100`입니다. diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 4f1e904d4b0..4cb0c110238 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -211,7 +211,7 @@ Kiro 로그인에는 Kiro CLI가 필요합니다. Unix에서는 `curl -fsSL http ## 3. API 키 카탈로그 -opencodex에는 빌트인 프리셋이 105개 들어 있습니다. 키 방식 84개, OAuth 17개, 로컬 3개, +opencodex에는 빌트인 프리셋이 106개 들어 있습니다. 키 방식 85개, OAuth 17개, 로컬 3개, 기본 ChatGPT 포워드 프리셋 1개입니다. 대시보드의 **Add provider** 선택기는 키 발급 페이지를 열고, 입력한 키를 검증한 뒤 저장합니다(검증은 프로바이더별로 다릅니다). 주요 항목은 다음과 같습니다: diff --git a/docs-site/src/content/docs/ru/getting-started/quickstart.md b/docs-site/src/content/docs/ru/getting-started/quickstart.md index 7906982abe4..99d6cdb2a1b 100644 --- a/docs-site/src/content/docs/ru/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ru/getting-started/quickstart.md @@ -18,7 +18,7 @@ ocx init `ocx init` проведёт вас по следующим шагам: -1. **Выбор провайдера** — выберите один из 105 встроенных пресетов реестра или `custom`, чтобы +1. **Выбор провайдера** — выберите один из 106 встроенных пресетов реестра или `custom`, чтобы ввести базовый URL и адаптер вручную. 2. **API-ключ** — вставьте ключ или сошлитесь на переменную окружения вида `${ANTHROPIC_API_KEY}`. 3. **Модель по умолчанию** — для провайдеров с ключом, локальных и `custom` примите значение из diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index 525053d9838..66c7357be33 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -220,7 +220,7 @@ Inline JSON и лишние позиционные аргументы откло ## 3. Каталог API-ключей -opencodex поставляется с 105 встроенными пресетами: 84 на основе ключей, 17 OAuth, три локальных и +opencodex поставляется с 106 встроенными пресетами: 85 на основе ключей, 17 OAuth, три локальных и один пресет ChatGPT-форварда по умолчанию. Селектор **Add provider** в дашборде открывает страницу выдачи ключей провайдера, проверяет ключ и сохраняет его; проверка зависит от провайдера. Наиболее заметные записи: diff --git a/docs-site/src/content/docs/tr/getting-started/quickstart.md b/docs-site/src/content/docs/tr/getting-started/quickstart.md index 4304cf7724b..27e9f1d95a7 100644 --- a/docs-site/src/content/docs/tr/getting-started/quickstart.md +++ b/docs-site/src/content/docs/tr/getting-started/quickstart.md @@ -14,7 +14,7 @@ ocx init `ocx init` adım adım size rehberlik eder: -1. **Bir sağlayıcı seçin** — yerleşik kayıt defterindeki 105 önayardan birini +1. **Bir sağlayıcı seçin** — yerleşik kayıt defterindeki 106 önayardan birini veya bir temel URL ile adaptör yazmak için `custom` seçeneğini belirleyin. 2. **API anahtarı** — bir anahtar yapıştırın veya `${ANTHROPIC_API_KEY}` gibi bir ortam değişkenine başvurun. diff --git a/docs-site/src/content/docs/tr/guides/providers.md b/docs-site/src/content/docs/tr/guides/providers.md index 3851ec43c6c..76de995c6d3 100644 --- a/docs-site/src/content/docs/tr/guides/providers.md +++ b/docs-site/src/content/docs/tr/guides/providers.md @@ -341,7 +341,7 @@ olmayan bir makineden oturum açmak bundan etkilenmez. ## 3. API anahtarı kataloğu -opencodex 105 yerleşik önayar ile birlikte gelir: 84 anahtar tabanlı, 17 +opencodex 106 yerleşik önayar ile birlikte gelir: 85 anahtar tabanlı, 17 OAuth, üç yerel ve bir varsayılan ChatGPT iletme önayarı. Kontrol panelinin **Sağlayıcı ekle** seçicisi bir anahtar sağlayıcısının kontrol panelini açar, anahtarı doğrular ve saklar; doğrulama sağlayıcıya özgüdür. Dikkate değer diff --git a/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md b/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md index 6849f78b604..76d866052f8 100644 --- a/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md +++ b/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md @@ -18,7 +18,7 @@ ocx init `ocx init` 会引导你完成: -1. **选择 provider** — 从内置 registry 的 105 个预设中选择一个,或选择 `custom` 手动输入 base URL 和 adapter。 +1. **选择 provider** — 从内置 registry 的 106 个预设中选择一个,或选择 `custom` 手动输入 base URL 和 adapter。 2. **API key** — 粘贴一个 key,或引用一个环境变量,例如 `${ANTHROPIC_API_KEY}`。 3. **默认模型** — 对于 key、本地和 custom provider,接受预设值或输入模型 id。 4. **代理端口** — 默认为 `10100`。 diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index 89b0b8d3f3d..cdf84045d8a 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -218,7 +218,7 @@ API Key 提供商,也不负责让 WorkBuddy 桌面客户端使用 OpenCodex ## 3. API 密钥目录 -opencodex 内置 105 个预设:84 个密钥预设、17 个 OAuth 预设、3 个本地预设,以及 1 个默认的 +opencodex 内置 106 个预设:85 个密钥预设、17 个 OAuth 预设、3 个本地预设,以及 1 个默认的 ChatGPT 转发预设。仪表盘的 **Add provider** 选择器会打开密钥提供商的控制台,验证并保存密钥。 验证因提供商而异。主要条目包括: diff --git a/docs-site/src/content/docs/zh-tw/getting-started/quickstart.md b/docs-site/src/content/docs/zh-tw/getting-started/quickstart.md index 5d120c5d723..ea72d3cfcae 100644 --- a/docs-site/src/content/docs/zh-tw/getting-started/quickstart.md +++ b/docs-site/src/content/docs/zh-tw/getting-started/quickstart.md @@ -13,7 +13,7 @@ ocx init `ocx init` 會引導你完成: -1. **選擇 provider** —— 從內建 registry 的 105 個預設中選擇一個,或選擇 `custom` 手動輸入 +1. **選擇 provider** —— 從內建 registry 的 106 個預設中選擇一個,或選擇 `custom` 手動輸入 base URL 和 adapter。 2. **API key** —— 貼上一個 key,或引用一個環境變數,例如 `${ANTHROPIC_API_KEY}`。 3. **預設模型** —— 對於 API key、本機和 custom provider,可接受預設值或輸入模型 id。 diff --git a/docs-site/src/content/docs/zh-tw/guides/providers.md b/docs-site/src/content/docs/zh-tw/guides/providers.md index 2fd7c0749dc..460b9b85b70 100644 --- a/docs-site/src/content/docs/zh-tw/guides/providers.md +++ b/docs-site/src/content/docs/zh-tw/guides/providers.md @@ -263,7 +263,7 @@ database 並移除目前的 WAL、SHM 與 journal sidecar,再發布先前的 s ## 3. API 金鑰目錄 -opencodex 內建 105 個 preset:84 個 key-based、17 個 OAuth、3 個 local,以及 1 個預設 ChatGPT-forward +opencodex 內建 106 個 preset:85 個 key-based、17 個 OAuth、3 個 local,以及 1 個預設 ChatGPT-forward preset。儀表板的 **Add provider** picker 會開啟 key provider 的 dashboard、驗證金鑰並儲存;驗證方式 依 provider 而異。主要條目如下。 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 53debea0645..e4b15eb7c8e 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"], - "explicit": { "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": { "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", "service-proxy-env.test.ts": "service", "openai-chat-standalone-bare-block.test.ts": "adapters/openai", "owner-registry-isolation.test.ts": "config", "responses-auto-pause-replay-owner.test.ts": "responses", "responses-account-auto-pause.test.ts": "responses", "oauth-account-auto-pause.test.ts": "oauth", "responses-account-auto-pause-wire-owner.test.ts": "responses", "oauth-upsert-paused-api-key.test.ts": "oauth", "management-account-auto-pause.test.ts": "server", "cli-account-key-pause.test.ts": "cli", "oauth-account-auto-pause-revision.test.ts": "oauth", "config-account-auto-pause-revisions.test.ts": "config", "codex-history-pending-wal.test.ts": "codex-integration", "responses-anthropic-billing-send-budget.test.ts": "responses", "anthropic-billing-ownership.test.ts": "adapters/anthropic", "server-anthropic-billing.test.ts": "server", "server-anthropic-billing-boundaries.test.ts": "server", "router-anthropic-billing-failover.test.ts": "routing", "anthropic-billing-refusal.test.ts": "adapters/anthropic", "messages-request-id-headers.test.ts": "claude-integration", "bounded-file-read.test.ts": "lib", "responses-undeclared-tool-guard-inbound.test.ts": "responses", "explicit-empty-tool-catalog.test.ts": "responses", "explicit-empty-tool-catalog-history.test.ts": "responses", @@ -1366,7 +1366,7 @@ "opengateway-key-validation.test.ts": "providers", "openrouter-provider-routing.test.ts": "providers", "openrouter-quota-reset-cooldown-4024.test.ts": "providers", - "opper-provider.test.ts": "providers", "modelflare-provider.test.ts": "providers", + "opper-provider.test.ts": "providers", "modelflare-provider.test.ts": "providers", "aistupidlevel-provider.test.ts": "providers", "tokenlab-protocols.test.ts": "providers", "tokenlab-provider.test.ts": "providers", "optional-shutdown-hooks.test.ts": "lib", diff --git a/src/codex/catalog/gather-capture.ts b/src/codex/catalog/gather-capture.ts index f193fd7a3d9..e60d18d882a 100644 --- a/src/codex/catalog/gather-capture.ts +++ b/src/codex/catalog/gather-capture.ts @@ -552,6 +552,7 @@ function providerCatalogFingerprint(name: string, prov: OcxProviderConfig): Reco maxOut: prov.modelMaxOutputTokens ?? null, autoCompact: prov.modelAutoCompactTokenLimits ?? null, inMod: prov.modelInputModalities ?? null, + inModAll: prov.inputModalities ?? null, capabilities: prov.modelCapabilities ?? null, re: prov.modelReasoningEfforts ?? null, suppressMax: prov.modelSuppressSyntheticMax ?? null, diff --git a/src/config/load-degrade.ts b/src/config/load-degrade.ts index 2fba9e30102..342ca547722 100644 --- a/src/config/load-degrade.ts +++ b/src/config/load-degrade.ts @@ -16,6 +16,7 @@ import { autoReviewModelOverridesConfigError, autoReviewModelTargetConfigError, modelCapabilitiesConfigError, + providerInputModalitiesConfigError, sanitizeModelCapabilitiesForLoad, modelDisplayNamesConfigError, } from "./provider-validation"; @@ -359,6 +360,10 @@ export function sanitizeCapabilityDeclarationsForLoad(parsed: unknown): void { for (const [name, value] of Object.entries(providers)) { if (!value || typeof value !== "object" || Array.isArray(value)) continue; const provider = value as Record; + if (providerInputModalitiesConfigError(provider.inputModalities) !== null) { + console.warn(`config.json provider ${JSON.stringify(redactSecretString(name))} has malformed inputModalities; restricting input modalities to text`); + provider.inputModalities = ["text"]; + } if (provider.modelCapabilities === undefined) continue; if (modelCapabilitiesConfigError(provider.modelCapabilities) !== null) { console.warn(`config.json provider ${JSON.stringify(redactSecretString(name))} has malformed modelCapabilities; retaining valid axes and restricting malformed input modalities to text`); diff --git a/src/config/provider-validation.ts b/src/config/provider-validation.ts index 598e7d6db40..3e707f9a9de 100644 --- a/src/config/provider-validation.ts +++ b/src/config/provider-validation.ts @@ -445,6 +445,14 @@ function capabilityRecord(value: unknown): value is Record { && [Object.prototype, null].includes(Object.getPrototypeOf(value)); } +/** Provider-wide fallback; the same modality vocabulary as exact model declarations. */ +export function providerInputModalitiesConfigError(value: unknown): string | null { + if (value === undefined) return null; + return Array.isArray(value) && value.length > 0 + && value.every(item => typeof item === "string" && ["text", "image", "audio", "video"].includes(item)) + ? null : "inputModalities must be a nonempty array of text, image, audio or video"; +} + /** Strict per-model context-tier values; PATCH accepts null to clear the entire map. */ export function contextTierRecordConfigError(value: unknown, allowClear = false): string | null { if (value === undefined || (allowClear && value === null)) return null; diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts index 4a8615d3ed1..ddd20cf8688 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -13,6 +13,7 @@ import { normalizeNonBlankStringArray, normalizeAutoReviewModelOverrides, modelCapabilitiesConfigError, + providerInputModalitiesConfigError, contextTierRecordConfigError, mergeModelCapabilities, } from "../provider-validation"; @@ -310,6 +311,10 @@ export const providerConfigSchema = z.object({ // Raw diagnostics/writes reject malformed provenance; tolerant loads preserve the custom row. anthropicOAuthInstance: z.literal("anthropic2").optional().catch(undefined), modelCapabilities: modelCapabilitiesSchema.optional(), + inputModalities: z.unknown().superRefine((value, ctx) => { + const error = providerInputModalitiesConfigError(value); + if (error) ctx.addIssue({ code: "custom", message: error }); + }).optional().transform(value => value as OcxProviderConfig["inputModalities"]), modelContextTiers: z.unknown().superRefine((value, ctx) => { const error = contextTierRecordConfigError(value); if (error) ctx.addIssue({ code: "custom", message: error }); diff --git a/src/providers/derive.ts b/src/providers/derive.ts index 7f2c5a60367..900500d22f1 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -7,6 +7,7 @@ import { mapFill, nestedMapFill, stableUnion } from "./resolved-model-policy-mer import { mergeReasoningModelCapabilities } from "./reasoning-capabilities"; import { PROVIDER_REGISTRY, + normalizedProviderEndpoint, registryEntryForProviderDestination, registryModelServiceTierCapabilityApplies, type ProviderRegistryEntry, @@ -522,6 +523,23 @@ export function hasLegacyClinePassReasoningEfforts(name: string, prov: OcxProvid && prov.reasoningEfforts[0] === "low"; } +/** + * The registry's provider-wide `inputModalities` for a row that still points at that entry's + * destination. Runtime-only: callers apply it to a private copy (vision eligibility) or to the + * routed provider. Enrichment does not copy it, because init and management writes persist the + * enriched object, and a saved copy would follow the row to another URL. + */ +export function registryInputModalitiesForDestination(name: string, prov: OcxProviderConfig): string[] | undefined { + let entry = PROVIDER_REGISTRY.find(row => row.id === name); + if (!entry || !providerMatchesRegistryTransportWithStaticGuards(name, prov)) { + entry = registryEntryForProviderDestination(prov); + // Runtime modality authority follows only the canonical fixed-key destination, not aliases. + if (entry && (entry.adapter !== prov.adapter + || normalizedProviderEndpoint(entry.baseUrl) !== normalizedProviderEndpoint(prov.baseUrl))) return undefined; + } + return entry?.inputModalities ? [...entry.inputModalities] : undefined; +} + export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig): void { const entry = PROVIDER_REGISTRY.find(row => row.id === name); if (!entry || !providerMatchesRegistryTransportWithStaticGuards(name, prov)) { diff --git a/src/providers/model-rename-fields.ts b/src/providers/model-rename-fields.ts index 54d18ca0266..7638a46d33f 100644 --- a/src/providers/model-rename-fields.ts +++ b/src/providers/model-rename-fields.ts @@ -67,6 +67,7 @@ export const PROVIDER_MODEL_RENAME_ROLES = { modelContextWindows: "record", modelContextTiers: "record", modelInputModalities: "record", + inputModalities: "none", modelCapabilities: "record", modelMaxInputTokens: "record", modelAutoCompactTokenLimits: "record", diff --git a/src/providers/registry/entries-extended.ts b/src/providers/registry/entries-extended.ts index e88f914ad36..68c6adc5c81 100644 --- a/src/providers/registry/entries-extended.ts +++ b/src/providers/registry/entries-extended.ts @@ -111,6 +111,10 @@ import { OPPER_MODEL_CONTEXT_WINDOWS, OPPER_MODEL_MAX_OUTPUT_TOKENS, OPPER_MODEL_INPUT_MODALITIES, + AISTUPIDLEVEL_ROUTING_MODES, + AISTUPIDLEVEL_MODEL_CONTEXT_WINDOWS, + AISTUPIDLEVEL_MODEL_MAX_OUTPUT_TOKENS, + AISTUPIDLEVEL_MODEL_INPUT_MODALITIES, STEPFUN_MODELS, STEPFUN_MODEL_CONTEXT_WINDOWS, STEPFUN_MODEL_INPUT_MODALITIES, @@ -1288,6 +1292,33 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ models: ["glm-5.3-flash", "claude-sonnet-5-5", "gpt-6.1-sol", "deepseek-v4-flash"], note: "OpenAI-compatible relay for Claude, GPT, GLM and DeepSeek chat models. The seeded models were verified with chat, streaming and tool calls; deepseek-v4.1-flash is listed upstream but currently answers 503 and is left out. An ordinary key only reaches its own routing group's models; a Smart key reaches all seeded models. Image and video models are not served through this Chat preset. Upstream latency varies widely (a few seconds to about 30 s) with occasional transient 503s.", }, + { + // AI Stupid Level Smart Router, operated by S.C. Studio Platforms S.R.L. Contract checked + // 2026-10-26: https://aistupidlevel.info/router/docs and https://aistupidlevel.info/terms#router. + // The router calls the model it picks with the provider keys saved on the user's own ASL + // account, so each provider bills the user directly; ASL resells no model access. GET /v1/models + // answers 401 without a valid key, so the default discovery URL doubles as key validation. The + // router's /v1/messages is a plain Anthropic passthrough with no routing, so every inbound, + // Claude Code included, stays on Chat. The env reference derived from the id is + // ${AISTUPIDLEVEL_API_KEY}. + id: "aistupidlevel", + label: "AI Stupid Level", + adapter: "openai-chat", + baseUrl: "https://aistupidlevel.info/v1", + authKind: "key", + dashboardUrl: "https://aistupidlevel.info/router/keys", + liveModels: true, + preserveCustomDestination: true, + defaultModel: "auto-coding", + models: AISTUPIDLEVEL_ROUTING_MODES, + modelContextWindows: AISTUPIDLEVEL_MODEL_CONTEXT_WINDOWS, + modelMaxOutputTokens: AISTUPIDLEVEL_MODEL_MAX_OUTPUT_TOKENS, + modelInputModalities: AISTUPIDLEVEL_MODEL_INPUT_MODALITIES, + // Text only for every id, including the pinned models live discovery adds: the router + // answers an image, audio or file part with 400 `unsupported_content`. + inputModalities: ["text"], + note: "Smart Router that sends each request to a model chosen from AI Stupid Level's benchmarks, using the provider keys saved on your AI Stupid Level account (OpenAI, Anthropic, Google, DeepSeek, Kimi, GLM), so each provider bills you directly with no markup. Routing modes: auto-coding (the default), auto-tooling, auto-reasoning, auto-best, plus value and speed modes. A real model id from the live list, such as claude-opus-5-5, pins one model. The X-AISM-Model response header names the model that answered. Text only.", + }, // Xiaomi retires mimo-v2.5 and mimo-v2.5-pro on 2026-10-21 with no redirect // (https://mimo.mi.com/docs/en-US/updates/deprecate), so the first-party presets default to V2.6. // Saved defaults are not rewritten; V2.5 stays listed until it stops answering. diff --git a/src/providers/registry/model-ids.ts b/src/providers/registry/model-ids.ts index f00edad7f35..ed882732f88 100644 --- a/src/providers/registry/model-ids.ts +++ b/src/providers/registry/model-ids.ts @@ -99,6 +99,7 @@ export const REGISTRY_FIELD_MODEL_ID_ROLES = { modelContextWindows: RECORD_KEYS, modelDisplayNames: RECORD_KEYS, modelInputModalities: RECORD_KEYS, + inputModalities: NONE, modelCapabilities: RECORD_KEYS, defaultMaxOutputTokens: NONE, modelMaxOutputTokens: RECORD_KEYS, diff --git a/src/providers/registry/model-seeds.ts b/src/providers/registry/model-seeds.ts index 166e90e4752..4d6ddcd28d4 100644 --- a/src/providers/registry/model-seeds.ts +++ b/src/providers/registry/model-seeds.ts @@ -1204,6 +1204,27 @@ export const OPPER_MODEL_INPUT_MODALITIES: Record = Object.fro OPPER_MODELS.map(id => [id, OPPER_TEXT_ONLY_MODELS.includes(id) ? ["text"] : ["text", "image"]]), ); +// AI Stupid Level routing modes, as listed by the authenticated GET /v1/models on 2026-10-26 +// (https://aistupidlevel.info/router/docs#strategies). Each mode picks a model per request, so it +// declares conservative limits that hold for any pick: a 262,144-token window (the pool's smallest, +// Kimi K2.7 Code's, when first checked on 2026-10-23) and 64,000 output tokens. +// The router is text only (image, audio and file parts are a 400 `unsupported_content`), so images +// for a mode go through the vision sidecar. Pinned model ids arrive through live discovery. +export const AISTUPIDLEVEL_ROUTING_MODES = [ + "auto-coding", "auto-tooling", "auto-reasoning", "auto-best", "auto", "auto-task", + "auto-value", "auto-value-coding", "auto-value-tooling", "auto-value-reasoning", + "auto-fastest-quality", "auto-consistent", "auto-cheapest", "auto-fastest", "auto-split", +]; +export const AISTUPIDLEVEL_MODEL_CONTEXT_WINDOWS: Record = Object.fromEntries( + AISTUPIDLEVEL_ROUTING_MODES.map(id => [id, 262_144]), +); +export const AISTUPIDLEVEL_MODEL_MAX_OUTPUT_TOKENS: Record = Object.fromEntries( + AISTUPIDLEVEL_ROUTING_MODES.map(id => [id, 64_000]), +); +export const AISTUPIDLEVEL_MODEL_INPUT_MODALITIES: Record = Object.fromEntries( + AISTUPIDLEVEL_ROUTING_MODES.map(id => [id, ["text"]]), +); + export const STEPFUN_MODELS = [ "step-5-preview", "step-3.5-flash", diff --git a/src/providers/registry/types.ts b/src/providers/registry/types.ts index c5ce5bc51a1..7bb127022cf 100644 --- a/src/providers/registry/types.ts +++ b/src/providers/registry/types.ts @@ -318,6 +318,14 @@ export interface ProviderRegistryEntry { */ modelDisplayNames?: Record; modelInputModalities?: Record; + /** + * Provider-wide input modalities for every model `modelInputModalities` does not enumerate, + * including ids that arrive from live discovery. Declare it where the limit belongs to the + * provider's API rather than to one model; a per-model entry still wins over it. Registry-only + * and runtime-only: never seeded or enriched into saved config, and applied only while a provider + * points at this entry's destination (routed provider, vision eligibility). + */ + inputModalities?: string[]; modelCapabilities?: OcxProviderConfig["modelCapabilities"]; defaultMaxOutputTokens?: number; modelMaxOutputTokens?: Record; diff --git a/src/providers/routed-config.ts b/src/providers/routed-config.ts index 97f0f8ae6c1..b3f0d86a378 100644 --- a/src/providers/routed-config.ts +++ b/src/providers/routed-config.ts @@ -13,7 +13,7 @@ import { captureProviderApiKeySelection } from "./api-key-selection-capture"; import { assertProviderDestinationAllowed } from "../lib/destination-policy"; import { redactSecretString, redactUrlForLog } from "../lib/redact"; import { PROVIDER_REGISTRY, registryEntryForProviderDestination } from "./registry"; -import { applyDirectReasoningEffortContracts, hasLegacyClinePassReasoningEfforts } from "./derive"; +import { applyDirectReasoningEffortContracts, hasLegacyClinePassReasoningEfforts, registryInputModalitiesForDestination } from "./derive"; import { cloneFastWire } from "./fastwire"; import { fastSwitchOff } from "./fast-opt-in"; import { @@ -122,8 +122,11 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider // the reasoning item the upstream answers 400 to. Destination matching refuses templated and // overridable base URLs, so this cannot follow a retargeted row, and an explicit value wins. const destination = registryEntryForProviderDestination(provider); + const inputModalities = provider.inputModalities === undefined + ? registryInputModalitiesForDestination(providerName, provider) : undefined; return { ...provider, + ...(inputModalities ? { inputModalities } : {}), apiKey: usableResolvedApiKey(provider.apiKey), ...(destination?.modelDefaultReasoningEfforts ? { modelDefaultReasoningEfforts: mapFill(destination.modelDefaultReasoningEfforts, provider.modelDefaultReasoningEfforts)[0], @@ -298,6 +301,11 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider ...(provider.project === undefined && registryEntry.project !== undefined ? { project: registryEntry.project } : {}), ...(provider.location === undefined && registryEntry.location !== undefined ? { location: registryEntry.location } : {}), ...(provider.contextWindow === undefined && registryEntry.contextWindow !== undefined ? { contextWindow: registryEntry.contextWindow } : {}), + // The request path never calls enrichProviderFromRegistry(), so a row saved before the + // registry declared a provider-wide modality must inherit it here to reach the vision gate. + ...(provider.inputModalities === undefined && registryEntry.inputModalities !== undefined + ? { inputModalities: [...registryEntry.inputModalities] } + : {}), ...((provider.reasoningEfforts === undefined || hasLegacyClinePassReasoningEfforts(providerName, provider)) && registryEntry.reasoningEfforts !== undefined ? { reasoningEfforts: [...registryEntry.reasoningEfforts] } diff --git a/src/routing/capability.ts b/src/routing/capability.ts index 7f26e8bbdf5..53ac3c50c36 100644 --- a/src/routing/capability.ts +++ b/src/routing/capability.ts @@ -182,7 +182,8 @@ export function candidateCapabilityEvidence( ? (nativeOpenAiContextWindow(modelId, nativeContextLimits(config)) ?? rawContextWindow) : rawContextWindow; - // `noVisionModels` is checked before the modality chain because that is the order + // Exact capability declarations precede legacy vision hints, matching runtime eligibility. + // `noVisionModels` is checked before the remaining modality chain because that is the order // `isModelTextOnly` uses: it matches the no-vision list and returns true before it // ever reads `modelInputModalities` (`src/vision/index.ts:32`). So a `gpt-oss` // no-vision entry beats an exact `gpt-oss:120b` entry that lists "image", and @@ -191,10 +192,15 @@ export function candidateCapabilityEvidence( // #2086: routing *acts* on this evidence, so it would select the candidate for image // work that execution then rejects. const noVision = modelInList(provider?.noVisionModels, modelId); - const modalities = noVision + const explicitModalities = Object.hasOwn(provider?.modelCapabilities ?? {}, modelId) + ? provider?.modelCapabilities?.[modelId]?.inputModalities : undefined; + const modalities = explicitModalities?.length ? explicitModalities : noVision ? ["text"] : (modelRecordValue(provider?.modelInputModalities, modelId) ?? modelRecordValue(registryEntry?.modelInputModalities, modelId) + // Provider-wide declaration, for ids the per-model maps do not list. Read from the + // provider only: a name-only registry lookup would apply it to a custom destination. + ?? provider?.inputModalities ?? catalogRow?.inputModalities ?? (isNative ? nativeInputModalities(modelId) : undefined)); const image = Array.isArray(modalities) diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 5b32e65e8e6..8bc3eab66bc 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -1,5 +1,5 @@ import { providerRelativeSendPathConfigError } from "../config/provider-relative-send-path"; -import { contextTierRecordConfigError, modelCapabilitiesConfigError } from "../config/provider-validation"; +import { contextTierRecordConfigError, modelCapabilitiesConfigError, providerInputModalitiesConfigError } from "../config/provider-validation"; import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; import { initialModelSelection } from "../providers/initial-model-selection"; import { extractAccountId } from "../oauth/chatgpt"; @@ -747,7 +747,7 @@ export function providerManagementConfigError( } const contextTiersError = contextTierRecordConfigError(raw.modelContextTiers); if (contextTiersError) return contextTiersError; - const capabilitiesError = modelCapabilitiesConfigError(raw.modelCapabilities); + const capabilitiesError = providerInputModalitiesConfigError(raw.inputModalities) ?? modelCapabilitiesConfigError(raw.modelCapabilities); if (capabilitiesError) return capabilitiesError; const pinsError = providerReasoningPinsConfigError(raw); if (pinsError) return pinsError; @@ -1110,6 +1110,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = { modelContextWindows: "editor", modelContextTiers: "editor", modelInputModalities: "editor", + inputModalities: "editor", modelCapabilities: "editor", modelMaxInputTokens: "runtime", modelAutoCompactTokenLimits: "editor", diff --git a/src/types/provider.ts b/src/types/provider.ts index d14f989d5f4..c41256d14b8 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -669,6 +669,12 @@ export interface OcxProviderConfig { modelContextTiers?: Record; /** Model-specific Codex catalog input modalities, e.g. ["text"] or ["text", "image"]. */ modelInputModalities?: Record; + /** + * Provider-wide input modalities, applied to models `modelInputModalities` does not enumerate + * (a live-discovered id, for example). The registry's value is applied at runtime only, for the + * preset's own destination, and never persisted; a per-model declaration still wins. + */ + inputModalities?: string[]; modelCapabilities?: Record; /** Model-specific max input token limits. Values cap auto_compact_token_limit. */ modelMaxInputTokens?: Record; diff --git a/src/vision/eligibility.ts b/src/vision/eligibility.ts index cdeefcc5b33..cb0cf01874b 100644 --- a/src/vision/eligibility.ts +++ b/src/vision/eligibility.ts @@ -25,7 +25,7 @@ import { modelRecordValue } from "../reasoning-effort"; import { getModelMetadataCaseInsensitive, resolveMetadataProvider } from "../generated/model-metadata"; import { nativeInputModalities } from "../codex/catalog/metadata"; import { SUPPORTED_NATIVE_OPENAI_SLUGS } from "../codex/catalog/native-models"; -import { enrichProviderFromRegistry } from "../providers/derive"; +import { enrichProviderFromRegistry, registryInputModalitiesForDestination } from "../providers/derive"; import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers-destination"; /** @@ -131,17 +131,19 @@ type EnrichedProviderCache = Map; * * `noVisionModels` is an explicit override. A modality declaration is only evidence for this * path when it describes a text model that excludes image input: an audio-only declaration is - * not a text-only model and must not be widened to image through the vision sidecar. + * not a text-only model and must not be widened to image through the vision sidecar. The + * provider-wide `inputModalities` covers ids the per-model map does not list, such as a model + * that arrived through live discovery; a per-model declaration still wins. */ export function isModelVisionSidecarConsumer( - provider: Pick, + provider: Pick, modelId: string, ): boolean { const declared = Object.hasOwn(provider.modelCapabilities ?? {}, modelId) ? provider.modelCapabilities?.[modelId]?.inputModalities : undefined; if (declared !== undefined) return declared.includes("text") && !declared.includes("image"); if (modelInList(provider.noVisionModels, modelId)) return true; - const modalities = modelRecordValue(provider.modelInputModalities, modelId); + const modalities = modelRecordValue(provider.modelInputModalities, modelId) ?? provider.inputModalities; return Array.isArray(modalities) && modalities.includes("text") && !modalities.includes("image"); } @@ -177,6 +179,7 @@ function enrichedProviderForVision( const enriched: OcxProviderConfig = { ...configured, ...(configured.noVisionModels ? { noVisionModels: [...configured.noVisionModels] } : {}), + ...(configured.inputModalities ? { inputModalities: [...configured.inputModalities] } : {}), ...(configured.modelInputModalities ? { modelInputModalities: Object.fromEntries( Object.entries(configured.modelInputModalities).map(([id, modalities]) => [id, [...modalities]]), @@ -193,6 +196,11 @@ function enrichedProviderForVision( } : {}), }; enrichProviderFromRegistry(providerName, enriched); + // Registry-only and runtime-only: applied to this private copy, never to saved config. + if (!enriched.inputModalities) { + const providerWide = registryInputModalitiesForDestination(providerName, configured); + if (providerWide) enriched.inputModalities = providerWide; + } cache.set(providerName, enriched); return enriched; } @@ -281,7 +289,9 @@ function modelAcceptsImageInputWithCache( // capability axis the CLI `--text-only` writes. const fromCustomRow = customRowAcceptsImageInput(config, candidate.provider, candidate.id); if (fromCustomRow !== undefined) return fromCustomRow; - const configuredModalities = provider ? modelRecordValue(provider.modelInputModalities, candidate.id) : undefined; + const configuredModalities = provider + ? modelRecordValue(provider.modelInputModalities, candidate.id) ?? provider.inputModalities + : undefined; const fromConfiguredModalities = advertisesImageInput(configuredModalities); if (fromConfiguredModalities !== undefined) return fromConfiguredModalities; const canonicalCodex = candidate.provider === "openai" diff --git a/src/vision/plan.ts b/src/vision/plan.ts index 67f4bc4a54f..d684a1400e4 100644 --- a/src/vision/plan.ts +++ b/src/vision/plan.ts @@ -121,7 +121,7 @@ function messagesHaveImage(parsed: OcxParsedRequest): boolean { */ export function requiresVisionPreprocessing( config: Pick & { customModels?: OcxConfig["customModels"] }, - provider: Pick, + provider: Pick, modelId: string, providerName?: string, ): boolean { @@ -145,7 +145,7 @@ export function requiresVisionPreprocessing( // the attachment to a model that cannot read it. if (customDeclared !== undefined) return !customDeclared.includes("image"); if (isModelTextOnly(provider, modelId)) return true; - const runtimeModalities = modelRecordValue(provider.modelInputModalities, modelId); + const runtimeModalities = modelRecordValue(provider.modelInputModalities, modelId) ?? provider.inputModalities; if (Array.isArray(runtimeModalities) && runtimeModalities.length > 0) { return !runtimeModalities.includes("image"); } diff --git a/structure/catalog.md b/structure/catalog.md index b48f4cb7b56..d947e22258a 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -40,7 +40,7 @@ registry read can change a running gather flight. Anthropic OAuth discovery in ` Policy is keyed by the final upstream wire model. Public alias and virtual-model identities remain diagnostic/catalog provenance and must be resolved before policy capture. Exact nonempty explicit input-modality declarations outrank the registry/config modality map; an empty declaration -falls through to that map. +falls through to that map. `src/routing/capability.ts` also reads exact `modelCapabilities` modalities before legacy vision hints and provider-wide defaults, matching runtime image eligibility. Anthropic numeric point releases inherit the nearest configured family context window before the provider-wide fallback. Exact model output limits precede the provider default output limit. For Azure OpenAI destinations (`*.openai.azure.com`), `src/providers/azure-model-metadata.ts` refreshes public models.dev Azure metadata during discovery under a two-second/16 MiB budget, coalesces callers per resolved config directory, caches for 24 hours, and keeps stale data offline. Snapshot, in-flight refresh and retry cooldown state are isolated by that directory; discovery admission captures that directory and threads it through refresh and all later hint lookups, including cached rows, configured retention, custom rows and combo members. Azure provider-result TTL and stale caches store only upstream rows; fresh, stale and cooldown reads apply root-specific metadata and configured retention after retrieval. Public metadata fetches reject redirects. `src/providers/derive.ts` merges published Azure fields over bundled Azure/vendor hints by exact or case-insensitive model id. Shared catalog hints fill missing input modalities and context/output limits, while explicit declarations, reported modalities/limits, sidecar coverage and configured caps retain precedence. Azure inference flags alone do not imply vision. Generation never fetches this snapshot, static policy remains unchanged, and arbitrary deployment aliases receive no guessed metadata. diff --git a/structure/config.md b/structure/config.md index e8752faa4c5..f3b1249b026 100644 --- a/structure/config.md +++ b/structure/config.md @@ -559,7 +559,7 @@ The optional `autoReviewOverride` block stores a global approval reviewer select Display-name validation retains prototype-shaped model IDs as data; reviewer-target map validation remains separate and rejects its reserved keys. ## Explicit per-model capability declarations -`modelCapabilities` on `src/types/provider.ts` stores exact model-ID entries with optional inputModalities, contextTier, video.processing, deepseekImageFiles and reasoning axes. The reasoning axes are strict optional booleans: `supportsReasoning`, `supportsThinkingToggle`, `supportsReasoningEffort` and `defaultThinkingEnabled`. Each merges independently over the registry row matched by case-insensitive model ID; an unrelated operator axis does not erase registry reasoning facts. Omitted axes may inherit registry or supported legacy evidence; absent evidence stays unknown. Explicit false is retained, and `supportsReasoning: false` suppresses both controls and the thinking default. These facts are separate from the `thinkingToggleModels` wire declaration and from client-only effort aliases; see [reasoning capability resolution](providers-and-adapters.md). +Provider-wide `inputModalities` is an optional nonempty array of `text`, `image`, `audio`, or `video`. `providerInputModalitiesConfigError` in `src/config/provider-validation.ts` validates both the config schema and management writes; malformed file values degrade to `["text"]` before parsing without rewriting the file or dropping the provider. Registry defaults remain runtime-only and apply only at the registry destination; exact model declarations outrank the provider fallback. `modelCapabilities` on `src/types/provider.ts` stores exact model-ID entries with optional inputModalities, contextTier, video.processing, deepseekImageFiles and reasoning axes. The reasoning axes are strict optional booleans: `supportsReasoning`, `supportsThinkingToggle`, `supportsReasoningEffort` and `defaultThinkingEnabled`. Each merges independently over the registry row matched by case-insensitive model ID; an unrelated operator axis does not erase registry reasoning facts. Omitted axes may inherit registry or supported legacy evidence; absent evidence stays unknown. Explicit false is retained, and `supportsReasoning: false` suppresses both controls and the thinking default. These facts are separate from the `thinkingToggleModels` wire declaration and from client-only effort aliases; see [reasoning capability resolution](providers-and-adapters.md). `src/config/provider-validation.ts` strictly validates writes and merges PATCH axes without sharing live objects; null map/model/axis/processing tombstones delete, while empty PATCH objects do nothing. Complete POST/PUT replacements reject tombstones. File reads retain valid sibling axes when a reasoning boolean is malformed; malformed explicit modalities restrict to text with a diagnostic. The two catalog writers receive explicit config and gather fingerprints include the map. This storage contract alone does not activate a context tier, advertise a larger window or enable video processing. Separately, `modelContextTiers` actively selects `default` or `long_context` for GitHub Copilot models: config and management writes validate exact IDs, PATCH merges entries and null clears the map, CLI edit writes the map, and OAuth login preserves it. diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index 4f0961dcb1d..849a624312a 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -15,6 +15,8 @@ retain their existing behavior. Mistral's registry entry in `src/providers/registry/entries-extended.ts` declares `zai-glm-5-3` native input as text-only, so registry enrichment enrolls that model in the enabled vision sidecar for existing configurations: the catalog advertises image attachments and request preprocessing sends GLM descriptions. Native Mistral vision models and explicit operator modalities retain precedence (`tests/vision/mistral-vision-sidecar.test.ts`). +A registry entry can also declare `inputModalities` for the whole provider. It covers ids the per-model maps do not list, such as models from live discovery, and a per-model declaration still wins. AI Stupid Level declares text, so an image for one of its pinned models is described or stripped instead of being sent to the router (`tests/providers/aistupidlevel-provider.test.ts`). + Final image/video client tool output follows the [explicit empty client-catalog boundary](../transports/responses-wire-shapes.md#declared-tool-membership-by-inbound-wire), after internal hosted operations and before client delivery or continuation state. Internal media execution retains its existing behavior. The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 8eacfda6774..e2790f64aa3 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -572,7 +572,7 @@ Pairing-grant source limiting applies only to invalid guesses from an allowed br ## Durable provider PATCH -`PUT /api/combos` shares this transaction for combo changes, rename references, disabled selectors and request-only Vision Sidecar enrollment; see `tests/routing/combo-vision-enrollment.test.ts`. +Provider editor snapshots and management writes validate provider-wide `inputModalities` through `providerManagementConfigError` in `src/server/auth-cors.ts`, sharing the [config modality contract](config.md#explicit-per-model-capability-declarations). Malformed declarations are refused before persistence or catalog refresh; the field remains editor-owned rather than a credential or derived DTO field. `PUT /api/combos` shares this transaction for combo changes, rename references, disabled selectors and request-only Vision Sidecar enrollment; see `tests/routing/combo-vision-enrollment.test.ts`. `src/server/management/provider-routes.ts` commits every provider PATCH variant through `src/server/management/provider-patch-transaction.ts`, including standalone default-provider diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 0fadbc20951..82ba0e379c5 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -86,7 +86,7 @@ Manual navigation is defined in `docs-site/astro.config.mjs`. When adding a publ sidebar and either add localized copies or intentionally accept Starlight fallback behavior. Provider preset totals are recounted from the current registry when a preset lands. The -documented split is 105 total: 84 key-based, 17 OAuth, three local, and one default +documented split is 106 total: 85 key-based, 17 OAuth, three local, and one default ChatGPT-forward preset. The English provider guide, all seven translated copies, and all eight quickstarts carry the same counts. diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 29ffb01290b..516db4b1191 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -194,7 +194,7 @@ rewrite rules and the routed-id settlement. | --- | --- | | `src/providers/registry.ts` | Compatibility facade; canonical provider presets for CLI, dashboard, OAuth, key providers, and metadata live in `src/providers/registry/entries-core.ts` and `entries-extended.ts`, with model seeds in `model-seeds.ts`. | | `src/providers/registry/model-ids.ts` | Classifies every `ProviderRegistryEntry` field by what its KEYS mean for selector decoding, and derives the native model ids an entry names. The classification is exhaustive by construction: a new registry field fails typecheck until its keys are given a meaning, which is what stops an identity-bearing map from being silently left out of decoding. Imported directly rather than through the facade, which is at its file-size cap. | -| `src/providers/derive.ts` | Enrichment from provider presets into user config. | +| `src/providers/derive.ts`, `src/providers/routed-config.ts` | Preset enrichment and runtime routing. Provider-wide input modalities stay runtime-only: renamed rows inherit them only at the canonical fixed-key URL and adapter, excluding custom destinations and aliases. Explicit provider and per-model declarations retain precedence. Vision eligibility and request routing share this fallback. | | `src/providers/model-rename-fields.ts`, `src/providers/model-rename-migration.ts` | Classifies every provider config field for a declared model rename. Exact-model records, lists and nested request-pacing keys follow the replacement; an already saved replacement entry wins. Provider-wide settings, including response-tier authority and project-context consent, and credential fields are not model identities. | | `src/providers/resolved-model-policy.ts`, `src/providers/resolved-model-policy-merge.ts` | Static provider/model policy resolution for the final upstream wire model, plus its pure clone/merge/URL/family helpers. The resolver detaches and freezes registry defaults, operator overrides, exact explicit input-modality declarations, provider-scoped hard wire pins (including Command Code's `claude-` prefix), aliases, and explicit false/empty values with field-level provenance. Provider derivation, routing, catalog hints, gather admission, and adapter selection consume its detached frozen result. Callers supply transport match, the exact capability row, and a credential-free effective auth decision; credential bytes, usability evidence, account/quota/health state, and observed limits remain outside the result. | diff --git a/structure/runtime.md b/structure/runtime.md index 3075c4720ec..452ef160218 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -477,7 +477,7 @@ and Anthropic image wire handling remain unchanged. Canonical ChatGPT Codex forwarding uses the generated `openai-codex` capability bundle rather than the public `openai` bundle. This matters when the two backends differ: for example, the vendored metadata records `gpt-5.3-codex-spark` as text-only on `openai-codex` while the public OpenAI row lists image input. The native Chat fast path and web-search image verbalization consume the same effective-capability decision. -An explicitly configured routed `visionSidecar.model` is dispatchable unless capability evidence positively proves it cannot accept images; an unknown custom sidecar is not guessed blind. If a proven text-only main target has no usable sidecar plan, image parts are stripped before the upstream request rather than forwarded raw. `modelInputModalities` is symmetric evidence: `["text","image"]` proves image support while `["text"]` triggers preprocessing. Runtime provider hooks such as injected `fetch` functions are preserved without mutation during capability enrichment. +An explicitly configured routed `visionSidecar.model` is dispatchable unless capability evidence positively proves it cannot accept images; an unknown custom sidecar is not guessed blind. If a proven text-only main target has no usable sidecar plan, image parts are stripped before the upstream request rather than forwarded raw. `modelInputModalities` is symmetric evidence: `["text","image"]` proves image support while `["text"]` triggers preprocessing. Provider-wide fallback declarations follow the [validated modality contract](config.md#explicit-per-model-capability-declarations); malformed saved values restrict to text before runtime enrichment. Runtime provider hooks such as injected `fetch` functions are preserved without mutation during capability enrichment. Regression coverage: `tests/vision/vision-cache.test.ts`, `tests/vision/vision-eligibility.test.ts`, `tests/vision/vision-routed.test.ts`, and `tests/adapters/openai/openai-chat-native-policy.test.ts`. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index a663d916769..7e5022b3319 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,4 @@ -{ "workbuddy-quota.test.ts": "providers", "workbuddy-login.test.ts": "oauth", "workbuddy-adapter.test.ts": "adapters", "workbuddy-provider.test.ts": "providers", "link-tunnel-history.test.ts": "clients", "link-readiness.test.ts": "clients", "link-diagnostics.test.ts": "clients", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "responses-phase-inference.test.ts": "responses", +{ "provider-input-modalities.test.ts": "providers", "workbuddy-quota.test.ts": "providers", "workbuddy-login.test.ts": "oauth", "workbuddy-adapter.test.ts": "adapters", "workbuddy-provider.test.ts": "providers", "link-tunnel-history.test.ts": "clients", "link-readiness.test.ts": "clients", "link-diagnostics.test.ts": "clients", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "responses-phase-inference.test.ts": "responses", "codex-catalog-heal-reanchor.test.ts": "codex-integration", "tool-identity-hash.test.ts": "adapters", "server-combo-required-tool-contract.test.ts": "server", @@ -1007,6 +1007,7 @@ "openrouter-provider-routing.test.ts": "providers", "openrouter-quota-reset-cooldown-4024.test.ts": "providers", "opper-provider.test.ts": "providers", "tokenlab-protocols.test.ts": "providers", "modelflare-provider.test.ts": "providers", + "aistupidlevel-provider.test.ts": "providers", "tokenlab-provider.test.ts": "providers", "optional-shutdown-hooks.test.ts": "lib", "orca-import.test.ts": "codex-integration", "orcarouter-provider.test.ts": "providers", "outbound-body-guard.test.ts": "server", "owned-service-home.test.ts": "server", diff --git a/tests/providers/aistupidlevel-provider.test.ts b/tests/providers/aistupidlevel-provider.test.ts new file mode 100644 index 00000000000..efcbe30ecfe --- /dev/null +++ b/tests/providers/aistupidlevel-provider.test.ts @@ -0,0 +1,229 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../../src/config"; +import { gatherRoutedModels } from "../../src/codex/catalog"; +import { clearModelCache } from "../../src/codex/model-cache"; +import { buildModelsRequest } from "../../src/oauth"; +import { KEY_LOGIN_PROVIDERS, validateApiKey } from "../../src/oauth/key-providers"; +import { deriveInitProviders, deriveProviderPresets, enrichProviderFromRegistry, providerConfigSeed } from "../../src/providers/derive"; +import { PROVIDER_REGISTRY } from "../../src/providers/registry"; +import { routeModel } from "../../src/router"; +import { candidateCapabilityEvidence } from "../../src/routing/capability"; +import { startServer } from "../../src/server"; +import { resolveWireProtocolOverride } from "../../src/server/adapter-resolve"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; +import { resetVisionDescriptionCache } from "../../src/vision"; +import { isModelVisionSidecarConsumer, isVisionSidecarConsumer } from "../../src/vision/eligibility"; +import { requiresVisionPreprocessing } from "../../src/vision/plan"; +import { withStubbedProviderFetch } from "../helpers/catalog-provider-fetch"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const originalFetch = globalThis.fetch; +const baseUrl = "https://aistupidlevel.info/v1"; +// The routing modes the authenticated GET /v1/models listed on 2026-10-23 and again on 2026-10-26. +const ROUTING_MODES = [ + "auto-coding", "auto-tooling", "auto-reasoning", "auto-best", "auto", "auto-task", + "auto-value", "auto-value-coding", "auto-value-tooling", "auto-value-reasoning", + "auto-fastest-quality", "auto-consistent", "auto-cheapest", "auto-fastest", "auto-split", +] as const; + +afterEach(() => { + globalThis.fetch = originalFetch; + clearModelCache("aistupidlevel"); +}); + +function entry() { + const row = PROVIDER_REGISTRY.find(candidate => candidate.id === "aistupidlevel"); + if (!row) throw new Error("missing AI Stupid Level preset"); + return row; +} + +function config(overrides: Partial = {}): OcxConfig { + return withStubbedProviderFetch({ + port: 10100, + defaultProvider: "aistupidlevel", + providers: { aistupidlevel: { ...providerConfigSeed(entry()), apiKey: "test-key", ...overrides } }, + }); +} + +describe("AI Stupid Level provider", () => { + test("is offered by ocx provider add, ocx init and the dashboard picker", () => { + expect(deriveInitProviders().find(row => row.id === "aistupidlevel")).toMatchObject({ + label: "AI Stupid Level", kind: "key", adapter: "openai-chat", baseUrl, + }); + const preset = deriveProviderPresets().find(row => row.id === "aistupidlevel"); + expect(preset).toMatchObject({ auth: "key", dashboardUrl: "https://aistupidlevel.info/router/keys" }); + expect(preset?.sponsor).toBeUndefined(); + expect(KEY_LOGIN_PROVIDERS.aistupidlevel?.defaultModel).toBe("auto-coding"); + }); + + test("seeds the routing modes as text-only, with limits that hold for any routed model", () => { + const seeded = config().providers.aistupidlevel!; + expect(seeded).toMatchObject({ adapter: "openai-chat", baseUrl, defaultModel: "auto-coding", liveModels: true }); + expect(entry().models).toEqual([...ROUTING_MODES]); + for (const mode of ROUTING_MODES) { + expect(seeded.modelContextWindows?.[mode]).toBe(262_144); + expect(seeded.modelMaxOutputTokens?.[mode]).toBe(64_000); + // Text-only: the vision sidecar describes an image before the request reaches the router. + expect(isModelVisionSidecarConsumer(seeded, mode)).toBe(true); + } + }); + + test.each(["auto-coding", "auto-tooling", "claude-opus-5-5"])("%s routes over Chat with its upstream id unchanged", model => { + const route = routeModel(config(), `aistupidlevel/${model}`); + expect(route.modelId).toBe(model); + expect(route.provider).toMatchObject({ adapter: "openai-chat", baseUrl }); + for (const inbound of ["responses", "chat", "anthropic"] as const) { + expect(resolveWireProtocolOverride("aistupidlevel", model, config().providers.aistupidlevel!, inbound).adapter) + .toBe("openai-chat"); + } + }); + + test("keeps a same-named custom destination instead of retargeting it", () => { + const custom = config({ baseUrl: "https://relay.example.test/v1" }); + expect(routeModel(custom, "aistupidlevel/custom-model").provider.baseUrl).toBe("https://relay.example.test/v1"); + }); + + test("lists routing modes and pinnable models from the authenticated live catalog", async () => { + let requests = 0; + globalThis.fetch = (async (input, init) => { + requests++; + expect(String(input)).toBe("https://aistupidlevel.info/v1/models"); + expect(new Headers(init?.headers).get("authorization")).toBe("Bearer test-key"); + // The upstream row shape: id, object, created and owned_by only. + return Response.json({ object: "list", data: [ + { id: "auto-coding", object: "model", created: 1792750208, owned_by: "aistupidlevel" }, + { id: "auto-tooling", object: "model", created: 1792750208, owned_by: "aistupidlevel" }, + { id: "claude-opus-5-5", object: "model", created: 1792750208, owned_by: "anthropic" }, + ] }); + }) as typeof fetch; + const models = (await gatherRoutedModels(config())).filter(row => row.provider === "aistupidlevel"); + expect(requests).toBe(1); + expect(models.map(row => row.id).sort()).toEqual(["auto-coding", "auto-tooling", "claude-opus-5-5"]); + }); + + test("validates a key against the key-gated model list and separates rejection from an outage", async () => { + expect(buildModelsRequest(config().providers.aistupidlevel!, "test-key", "aistupidlevel").url) + .toBe("https://aistupidlevel.info/v1/models"); + for (const [status, expected] of [[200, true], [401, false], [503, "unknown"]] as const) { + globalThis.fetch = (async (input, init) => { + expect(String(input)).toBe("https://aistupidlevel.info/v1/models"); + expect(new Headers(init?.headers).get("authorization")).toBe("Bearer test-key"); + return new Response(status === 200 ? JSON.stringify({ object: "list", data: [] }) : null, { status }); + }) as typeof fetch; + expect(await validateApiKey("aistupidlevel", KEY_LOGIN_PROVIDERS.aistupidlevel!, "test-key")).toBe(expected); + } + }); +}); + +describe("AI Stupid Level text-only contract for pinned models", () => { + // A live id that no registry map lists: the provider-wide declaration must cover it. + const PINNED = "claude-sonnet-4-6"; + const IMAGE = "data:image/png;base64,aGVsbG8taW1hZ2UtYnl0ZXM="; + let previousHome: string | undefined; + let home = ""; + let codexHome: IsolatedCodexHome; + let mainCalls: Array>; + + beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + codexHome = installIsolatedCodexHome("ocx-asl-text-only-codex-"); + home = mkdtempSync(join(tmpdir(), "ocx-asl-text-only-")); + process.env.OPENCODEX_HOME = home; + clearModelCache(); + resetVisionDescriptionCache(); + mainCalls = []; + globalThis.fetch = (async (input, init) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (url.origin === "https://aistupidlevel.info") { + if (url.pathname === "/v1/models") return Response.json({ object: "list", data: [{ id: PINNED, object: "model" }] }); + if (url.pathname === "/v1/chat/completions") { + mainCalls.push(JSON.parse(String(init?.body))); + return Response.json({ + id: "chatcmpl-asl-text-only", object: "chat.completion", model: PINNED, + choices: [{ index: 0, message: { role: "assistant", content: "No image here." }, finish_reason: "stop" }], + usage: { prompt_tokens: 10, completion_tokens: 4, total_tokens: 14 }, + }); + } + } + if (url.hostname === "127.0.0.1") return originalFetch(input, init); + throw new Error("Unexpected network destination in AI Stupid Level text-only fixture"); + }) as typeof fetch; + }); + + afterEach(() => { + clearModelCache(); + resetVisionDescriptionCache(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + codexHome.restore(); + removeTreeWithRetry(home); + }); + + test("a pinned live id needs vision preprocessing at the preset destination", () => { + const saved = config(); + // Registry-only: the saved row does not carry the declaration; routing backfills it. + expect(saved.providers.aistupidlevel!.inputModalities).toBeUndefined(); + const route = routeModel(saved, `aistupidlevel/${PINNED}`); + expect(route.modelId).toBe(PINNED); + expect(route.provider.inputModalities).toEqual(["text"]); + expect(requiresVisionPreprocessing(saved, route.provider, route.modelId, route.providerName)).toBe(true); + }); + + test("a same-named custom destination does not inherit the text-only declaration", () => { + const custom: OcxConfig = { + port: 10100, defaultProvider: "aistupidlevel", + providers: { aistupidlevel: { adapter: "openai-chat", baseUrl: "https://relay.example.test/v1", apiKey: "test-key" } }, + }; + // A row seeded from the preset and then pointed at another URL must drop it too. + const moved = config({ baseUrl: "https://relay.example.test/v1" }); + expect(moved.providers.aistupidlevel!.inputModalities).toBeUndefined(); + for (const candidate of [custom, moved]) { + const route = routeModel(candidate, `aistupidlevel/${PINNED}`); + expect(route.provider.baseUrl).toBe("https://relay.example.test/v1"); + expect(route.provider.inputModalities).toBeUndefined(); + expect(requiresVisionPreprocessing(candidate, route.provider, route.modelId, route.providerName)).toBe(false); + expect(candidateCapabilityEvidence(candidate, "aistupidlevel", PINNED).image).toBeUndefined(); + } + }); + + test("registry enrichment never writes the declaration into a saved provider object", () => { + const saved = config(); + enrichProviderFromRegistry("aistupidlevel", saved.providers.aistupidlevel!); + expect(saved.providers.aistupidlevel!.inputModalities).toBeUndefined(); + // The runtime classification still applies at the preset destination, and only there. + expect(isVisionSidecarConsumer(saved, "aistupidlevel", PINNED)).toBe(true); + expect(isVisionSidecarConsumer(config({ baseUrl: "https://relay.example.test/v1" }), "aistupidlevel", PINNED)).toBe(false); + }); + + test.each(["aistupidlevel", "my-asl"])("%s: an image sent to a pinned model never reaches the router", async providerName => { + saveConfig({ + port: 0, hostname: "127.0.0.1", defaultProvider: providerName, + providers: { [providerName]: { ...providerConfigSeed(entry()), apiKey: "fixture-asl-key" } }, + }); + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/responses", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: `${providerName}/${PINNED}`, stream: false, + input: [{ role: "user", content: [ + { type: "input_text", text: "Describe this image." }, { type: "input_image", image_url: IMAGE }, + ] }], + }), + }); + expect(response.status).toBe(200); + await response.text(); + expect(mainCalls).toHaveLength(1); + const forwarded = JSON.stringify(mainCalls[0]); + expect(forwarded).not.toContain(IMAGE); + expect(forwarded).not.toContain("image_url"); + expect(forwarded).toContain("[image omitted:"); + } finally { + await server.stop(true); + } + }); +}); diff --git a/tests/providers/provider-input-modalities.test.ts b/tests/providers/provider-input-modalities.test.ts new file mode 100644 index 00000000000..990b8d7bf37 --- /dev/null +++ b/tests/providers/provider-input-modalities.test.ts @@ -0,0 +1,222 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadConfig, saveConfig } from "../../src/config"; +import { sanitizeCapabilityDeclarationsForLoad } from "../../src/config/load-degrade"; +import { providerConfigSchema } from "../../src/config/schema/leaf-validators"; +import * as destinationPolicy from "../../src/lib/destination-policy"; +import { registryInputModalitiesForDestination } from "../../src/providers/derive"; +import { routedProviderConfig } from "../../src/providers/routed-config"; +import { candidateCapabilityEvidence } from "../../src/routing/capability"; +import { parseProviderEditorConfigDTO, providerEditorConfigDTO, providerManagementConfigError } from "../../src/server/auth-cors"; +import { handleManagementAPI } from "../../src/server/management-api"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; +import { isVisionSidecarConsumer } from "../../src/vision/eligibility"; +import { requiresVisionPreprocessing } from "../../src/vision/plan"; +import { catalogConvergenceFactory } from "../helpers/catalog-convergence"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { ManagementRequest } from "../helpers/management-auth"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const provider: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://aistupidlevel.info/v1" }; +const malformed = [null, "image", 42, true, {}, [], ["unknown"], ["text", 1], ["image", null], [["image"]]]; +const config = (row: OcxProviderConfig = provider): OcxConfig => ({ + port: 10100, defaultProvider: "aistupidlevel", providers: { aistupidlevel: { ...row } }, +}); + +describe("provider-wide modality write admission", () => { + test.each(malformed.map((value, index) => ({ value, index })))("schema rejects malformed value $index", ({ value }) => { + const result = providerConfigSchema.safeParse({ ...provider, inputModalities: value }); + expect(result.success).toBe(false); + if (!result.success) expect(result.error.issues.some(issue => issue.path[0] === "inputModalities")).toBe(true); + }); + + test.each(malformed.map((value, index) => ({ value, index })))("management rejects malformed value $index", ({ value }) => { + expect(providerManagementConfigError("aistupidlevel", { ...provider, inputModalities: value })).toContain("inputModalities"); + const editor = providerEditorConfigDTO(config()); + const next = structuredClone(editor); + (next.providers.aistupidlevel as Record).inputModalities = value; + // DTO parsing checks field ownership; the write route validates the parsed provider. + const parsed = parseProviderEditorConfigDTO(next); + expect(parsed.ok).toBe(true); + if (parsed.ok) expect(providerManagementConfigError("aistupidlevel", parsed.value.providers.aistupidlevel)).toContain("inputModalities"); + }); + + test("omission and every supported modality remain valid, with editor round-trip", () => { + for (const inputModalities of [undefined, ["text"], ["image"], ["audio"], ["video"], ["text", "image", "audio", "video"]]) { + const row = { ...provider, ...(inputModalities ? { inputModalities } : {}) }; + expect(providerConfigSchema.safeParse(row).success).toBe(true); + expect(providerManagementConfigError("aistupidlevel", row)).toBeNull(); + const dto = providerEditorConfigDTO(config(row)); + expect(dto.providers.aistupidlevel?.inputModalities).toEqual(inputModalities); + expect(parseProviderEditorConfigDTO(dto).ok).toBe(true); + } + }); + + test("the editor still refuses credentials, runtime-only and unknown fields", () => { + const dto = providerEditorConfigDTO(config({ ...provider, inputModalities: ["text"], apiKey: "fixture-key" })); + expect(dto.providers.aistupidlevel).not.toHaveProperty("apiKey"); + for (const field of ["apiKey", "apiKeyPool", "headers", "_apiKeyAttempt", "unknownProviderField"]) { + const next = structuredClone(dto); + (next.providers.aistupidlevel as Record)[field] = "fixture-key"; + expect(parseProviderEditorConfigDTO(next)).toMatchObject({ ok: false, code: "invalid_provider_editor_field" }); + } + }); +}); + +describe("tolerant modality load and management persistence", () => { + let previousHome: string | undefined; + let home: string; + let codexHome: IsolatedCodexHome; + beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-provider-modalities-")); + process.env.OPENCODEX_HOME = home; + codexHome = installIsolatedCodexHome("ocx-provider-modalities-codex-"); + }); + afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + codexHome.restore(); + removeTreeWithRetry(home); + }); + + test.each(malformed.map((value, index) => ({ value, index })))("malformed load $index restricts to text without losing the provider", ({ value }) => { + const raw = { ...config(), providers: { aistupidlevel: { ...provider, inputModalities: value } } }; + const warning = spyOn(console, "warn").mockImplementation(() => {}); + try { + const repaired = structuredClone(raw); + sanitizeCapabilityDeclarationsForLoad(repaired); + expect(repaired.providers.aistupidlevel.inputModalities).toEqual(["text"]); + writeFileSync(join(home, "config.json"), JSON.stringify(raw), "utf8"); + const loaded = loadConfig(); + expect(loaded.defaultProvider).toBe("aistupidlevel"); + expect(loaded.providers.aistupidlevel?.inputModalities).toEqual(["text"]); + const routed = routedProviderConfig("aistupidlevel", loaded.providers.aistupidlevel!); + expect(requiresVisionPreprocessing(loaded, routed, "unlisted-model", "aistupidlevel")).toBe(true); + expect(candidateCapabilityEvidence(loaded, "aistupidlevel", "unlisted-model", routed).image).toBe(false); + expect(warning).toHaveBeenCalled(); + expect(readFileSync(join(home, "config.json"), "utf8")).toBe(JSON.stringify(raw)); + } finally { warning.mockRestore(); } + }); + + test("valid declarations and omission survive tolerant loading", () => { + for (const inputModalities of [undefined, ["text", "image"]]) { + const raw = config({ ...provider, inputModalities }); + writeFileSync(join(home, "config.json"), JSON.stringify(raw), "utf8"); + expect(loadConfig().providers.aistupidlevel?.inputModalities).toEqual(inputModalities); + } + }); + + test.each(["create", "replace", "editor"])("%s refuses malformed modalities before memory, disk, DNS or catalog changes", async mode => { + const cfg = config(); + saveConfig(cfg); + const before = structuredClone(cfg); + const disk = readFileSync(join(home, "config.json")); + const resolved = spyOn(destinationPolicy, "providerDestinationResolvedError").mockResolvedValue(null); + let refreshes = 0; + try { + const baseline = providerEditorConfigDTO(cfg); + const next = structuredClone(baseline); + (next.providers.aistupidlevel as Record).inputModalities = "image"; + const url = new URL("http://127.0.0.1/api/providers"); + const response = await handleManagementAPI(new ManagementRequest(url, { + method: mode === "editor" ? "PUT" : "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify(mode === "editor" ? { baseline, next } : { + name: mode === "create" ? "new-provider" : "aistupidlevel", provider: { ...provider, inputModalities: "image" }, + }), + }), url, cfg, { createManagementConvergeCodex: catalogConvergenceFactory(() => { refreshes++; }) }); + expect(response?.status).toBe(400); + expect(await response!.json()).toMatchObject({ error: expect.stringContaining("inputModalities") }); + expect(cfg).toEqual(before); + expect(readFileSync(join(home, "config.json"))).toEqual(disk); + expect(resolved).not.toHaveBeenCalled(); + expect(refreshes).toBe(0); + } finally { resolved.mockRestore(); } + }); +}); + +describe("runtime provider modality authority", () => { + test("renamed fixed-key destinations retain vision eligibility without saving defaults", () => { + for (const baseUrl of [provider.baseUrl, " https://AISTUPIDLEVEL.info:443/v1/// "]) { + const saved = { ...provider, baseUrl }; + const cfg: OcxConfig = { port: 10100, providers: { "my-asl": saved } }; + const modalities = registryInputModalitiesForDestination("my-asl", saved); + expect(modalities).toEqual(["text"]); + modalities!.push("image"); + expect(registryInputModalitiesForDestination("my-asl", saved)).toEqual(["text"]); + expect(isVisionSidecarConsumer(cfg, "my-asl", "unlisted-model")).toBe(true); + expect(saved.inputModalities).toBeUndefined(); + } + }); + + test("renamed routed providers inherit a detached runtime modality fallback", () => { + const saved = { ...provider, baseUrl: " https://AISTUPIDLEVEL.info:443/v1/// " }; + const routed = routedProviderConfig("my-asl", saved); + expect(routed.inputModalities).toEqual(["text"]); + routed.inputModalities!.push("image"); + expect(routedProviderConfig("my-asl", saved).inputModalities).toEqual(["text"]); + expect(saved.inputModalities).toBeUndefined(); + }); + + test("renamed rows reject custom destinations and retain explicit modality authority", () => { + for (const row of [ + { ...provider, baseUrl: "https://relay.example.test/v1" }, + { ...provider, baseUrl: "https://aistupidlevel.info/another-path" }, + { ...provider, baseUrl: "https://aistupidlevel.info/v1?custom=1" }, + { ...provider, adapter: "openai-responses" as const }, + { ...provider, authMode: "oauth" as const }, + ]) { + expect(registryInputModalitiesForDestination("my-asl", row)).toBeUndefined(); + expect(routedProviderConfig("my-asl", row).inputModalities).toBeUndefined(); + expect(isVisionSidecarConsumer({ providers: { "my-asl": row } }, "my-asl", "unlisted-model")).toBe(false); + } + for (const override of [ + { inputModalities: ["text", "image"] }, + { modelInputModalities: { "unlisted-model": ["text", "image"] } }, + { modelCapabilities: { "unlisted-model": { inputModalities: ["text", "image"] } } }, + ]) { + const saved = { ...provider, ...override }; + const cfg: OcxConfig = { port: 10100, providers: { "my-asl": saved } }; + const routed = routedProviderConfig("my-asl", saved); + if (override.inputModalities) expect(routed.inputModalities).toEqual(["text", "image"]); + expect(isVisionSidecarConsumer(cfg, "my-asl", "unlisted-model")).toBe(false); + expect(requiresVisionPreprocessing(cfg, routed, "unlisted-model", "my-asl")).toBe(false); + expect(candidateCapabilityEvidence(cfg, "my-asl", "unlisted-model", routed).image).toBe(true); + } + }); + + test("registry backfill is destination-bound, cloned, and never saved", () => { + for (const baseUrl of [provider.baseUrl, `${provider.baseUrl}/`, " https://aistupidlevel.info/v1/ "]) { + const saved = { ...provider, baseUrl }; + const first = routedProviderConfig("aistupidlevel", saved); + expect(first.inputModalities).toEqual(["text"]); + first.inputModalities!.push("image"); + expect(routedProviderConfig("aistupidlevel", saved).inputModalities).toEqual(["text"]); + expect(saved.inputModalities).toBeUndefined(); + } + for (const row of [ + { ...provider, baseUrl: "https://relay.example.test/v1" }, + { ...provider, baseUrl: "https://aistupidlevel.info/another-path" }, + { ...provider, adapter: "openai-responses" as const }, + ]) { + expect(registryInputModalitiesForDestination("aistupidlevel", row)).toBeUndefined(); + expect(routedProviderConfig("aistupidlevel", row).inputModalities).toBeUndefined(); + } + expect(routedProviderConfig("aistupidlevel", { ...provider, inputModalities: ["text", "image"] }).inputModalities) + .toEqual(["text", "image"]); + }); + + test.each([true, false])("exact model declarations override opposing provider and legacy hints: image=$each", image => { + const row: OcxProviderConfig = { ...provider, inputModalities: image ? ["text"] : ["text", "image"], + modelInputModalities: { pinned: image ? ["text"] : ["text", "image"] }, + noVisionModels: image ? ["pinned"] : [], + modelCapabilities: { pinned: { inputModalities: image ? ["text", "image"] : ["text"] } }, + }; + const cfg = config(row); + const routed = routedProviderConfig("aistupidlevel", row); + expect(candidateCapabilityEvidence(cfg, "aistupidlevel", "pinned", routed).image).toBe(image); + expect(requiresVisionPreprocessing(cfg, routed, "pinned", "aistupidlevel")).toBe(!image); + }); +}); diff --git a/tests/providers/provider-registry-parity.test.ts b/tests/providers/provider-registry-parity.test.ts index cf40389c886..30f8170c7bc 100644 --- a/tests/providers/provider-registry-parity.test.ts +++ b/tests/providers/provider-registry-parity.test.ts @@ -1,4 +1,3 @@ -import { configuredReasoningEfforts } from "../../src/reasoning-effort"; import { describe, expect, spyOn, test } from "bun:test"; import { buildCatalogEntries } from "../../src/codex/catalog"; import { CURSOR_NO_VISION_MODELS } from "../../src/adapters/cursor/discovery"; @@ -42,15 +41,9 @@ function nativeTemplate(): Record { }; } -const EXPECTED_KEY_PROVIDER_IDS = [ - "anthropic-apikey", "openai-apikey", "meta-model", "umans", "opencode-go", "neuralwatt", "openrouter", "cline-pass", "cline", "orcarouter", "packycode", "bizrouter", "groq", "google", "google-vertex", "azure-openai", - "deepseek", "cerebras", "chutes", "deepinfra", "hyperbolic", "nscale", "vultr", "jev", "baseten", "commandcode", "sambanova", "nebius", "crusoe", "digitalocean", "scaleway", "featherless", "novita", "together", "fireworks", "firepass", "moonshot", - "huggingface", "nvidia", "venice", "zai", "zhipu-bigmodel", "zhipu-bigmodel-coding", "zhipu-bigmodel-responses", "nanogpt", "synthetic", "siliconflow", "qwen-cloud", "tencent-coding-plan", - "volcengine", "volcengine-coding-plan", "volcengine-agent-plan", "qianfan", "alibaba", "alibaba-token-plan", "alibaba-token-plan-intl", "parallel", "zenmux", "litellm", "ollama-cloud", "mistral", - "minimax", "minimax-cn", "kimi-code", "opencode-zen", "vercel-ai-gateway", "opper", "opengateway", "tokenlab", "modelflare", - "xiaomi", "xiaomi-mimo", "kilo", "mimo", "cloudflare-ai-gateway", "cloudflare-workers-ai", "gitlab-duo", - "qoder", "qoder-cn", "codebuddy", "codebuddy-cn", "stepfun", "claude-cli", -]; +const EXPECTED_KEY_PROVIDER_IDS = PROVIDER_REGISTRY + .filter(entry => entry.authKind === "key" && !entry.deprecated) + .map(entry => entry.id); describe("provider registry parity", () => { test("CodeBuddy static catalogs cover every official CLI-agent model in the bundled 2.143.0 manifest", () => { diff --git a/tests/vision/vision-text-only-predicate.test.ts b/tests/vision/vision-text-only-predicate.test.ts index e6f8c6e7194..c31024f87f3 100644 --- a/tests/vision/vision-text-only-predicate.test.ts +++ b/tests/vision/vision-text-only-predicate.test.ts @@ -41,6 +41,13 @@ describe("isModelTextOnly (#1024)", () => { test("suffixed model IDs are resolved through family lookup", () => { expect(isModelTextOnly(provider({ modelInputModalities: { "base-model": ["text"] } }), "base-model:extended")).toBe(true); }); + + test("provider-wide inputModalities cover unlisted ids while a per-model entry still wins", () => { + const textOnly = provider({ inputModalities: ["text"], modelInputModalities: { "vision-model": ["text", "image"] } }); + expect(isModelTextOnly(textOnly, "live-discovered-model")).toBe(true); + expect(isModelTextOnly(textOnly, "vision-model")).toBe(false); + expect(isModelTextOnly(provider({ inputModalities: ["text", "image"] }), "live-discovered-model")).toBe(false); + }); }); From 4111e93e3149a88f486882029e96a35f8ca5d1d4 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 27 Oct 2026 09:35:53 +0900 Subject: [PATCH 098/101] fix(claude): drain parent ACL flights before intercept secret writes (#7777) * fix(claude): drain parent ACL flights before intercept secret writes * fix(claude): bound ACL admission and guard picker cleanup steps * test(claude): yield a macrotask before barrier ordering assertions A single microtask yield depended on the current await count; draining queued microtasks with setImmediate keeps the assertions meaningful if later code adds awaits (CodeRabbit review on #7777). * fix(claude): admit nested Claude settings rewrites after the bind await Review r2 F3: startup migration and Desktop first-party removals rewrite the credential-bearing Claude settings file synchronously after an await. A CLAUDE_CONFIG_DIR nested in the opencodex home shares the config-root ACL flight, so gate the migration and every removal through withConfigDirHardeningSettled on the Claude settings home (removeDesktopFirstPartySettled maps a settle timeout to the existing unreadable refusal without writing). Regressions: nested-home migration and settled removal, red without the barrier, green with it. * fix(claude): admit model-default rewrites of a nested Claude home Review r3 F4: POST /api/claude-code/model-defaults rewrites Claude settings, its staged file and recovery backups synchronously after the body await; gate the whole operation through withConfigDirHardeningSettled(claudeConfigDir()) inside the existing failure mapping. N1: the gateway-apply cleanup refusal no longer claims a JSON parse failure. Also asserts that the startup migration actually rewrites the stale proxy URL (CodeRabbit). Regression: model-defaults POST with a held parent flight, red without the barrier. --------- Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> --- scripts/test-layout/layout-clients.json | 1 + src/claude/desktop-first-party.ts | 58 +- src/claude/desktop-picker.ts | 19 +- src/claude/first-party-settings.ts | 4 +- src/claude/intercept/local-ca.ts | 3 +- src/claude/intercept/picker-ca-cleanup.ts | 6 +- src/claude/intercept/picker-ca-startup.ts | 4 +- src/claude/intercept/picker-models.ts | 9 +- src/claude/intercept/picker-runtime.ts | 5 +- src/claude/intercept/runtime.ts | 9 +- src/cli/claude-desktop.ts | 17 +- src/cli/ensure-desired-integrations.ts | 14 +- src/config/paths.ts | 17 +- src/lib/windows-secret-acl.ts | 42 +- .../management/agent-settings-routes.ts | 28 +- .../management/native-integration-routes.ts | 19 +- structure/clients/claude-desktop.md | 4 +- structure/config.md | 6 +- structure/runtime.md | 6 +- .../claude-desktop-first-party.test.ts | 4 +- .../claude-intercept-acl-barrier.test.ts | 551 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 22 files changed, 756 insertions(+), 71 deletions(-) create mode 100644 tests/claude-integration/claude-intercept-acl-barrier.test.ts diff --git a/scripts/test-layout/layout-clients.json b/scripts/test-layout/layout-clients.json index 5420987b6e6..07b028ece83 100644 --- a/scripts/test-layout/layout-clients.json +++ b/scripts/test-layout/layout-clients.json @@ -1,4 +1,5 @@ { + "claude-intercept-acl-barrier.test.ts": "claude-integration", "agent-clients.test.ts": "clients", "workbuddy-selector-recovery.test.ts": "clients", "server-live-close.test.ts": "server", diff --git a/src/claude/desktop-first-party.ts b/src/claude/desktop-first-party.ts index 12a73cd5f1f..c1796ab5718 100644 --- a/src/claude/desktop-first-party.ts +++ b/src/claude/desktop-first-party.ts @@ -19,7 +19,8 @@ * into Claude Code's settings keeps `first-party` (observeClaudeDesktopMode), so moving the default * to gateway never flips a working Desktop under the operator. */ -import { getConfigDir } from "../config/paths"; +import { getConfigDir, withConfigDirHardeningSettled } from "../config/paths"; +import { WindowsSecretAclSettleTimeoutError } from "../lib/windows-secret-acl"; import { join } from "node:path"; import type { OcxConfig } from "../types"; import { claudeConfigDir } from "./auth-detect"; @@ -211,9 +212,35 @@ export function observeClaudeDesktopMode( return observed; } +const SECRET_FAILURE_CAUSES = ["EACCES", "EPERM", "ENOENT", "EEXIST", "EIO", "EBUSY", "ETIMEDOUT", + "EICACLS", "EACLIDENTITY", "local_ca_path_unsafe", "client_lifecycle_busy", "client_lifecycle_lock_failed", + "claude_intercept_proxy_token_invalid", "Error", "TypeError", "RangeError", "unknown"] as const; +export interface InterceptSecretDiagnostic { + stage: "ca" | "token"; + cause: typeof SECRET_FAILURE_CAUSES[number]; +} +function secretFailureCause(error: unknown): InterceptSecretDiagnostic["cause"] { + const code = error instanceof Error && "code" in error ? error.code : undefined; + // Allowlist labels, never arbitrary messages, paths, ACL stdout, or error-supplied names. + for (const allowed of SECRET_FAILURE_CAUSES) if (code === allowed) return allowed; + for (const allowed of ["TypeError", "RangeError", "Error"] as const) + if (error instanceof Error && error.name === allowed) return allowed; + return "unknown"; +} export type DesktopFirstPartyApplyResult = | { ok: true; changed: boolean; path: string; env: ClaudeInterceptEnv; proxyPort: number } - | { ok: false; reason: "intercept_disabled" | "ca_unavailable" | "unreadable" | "foreign_env"; path: string }; + | { ok: false; reason: "intercept_disabled" | "ca_unavailable" | "unreadable" | "foreign_env"; path: string; diagnostic?: InterceptSecretDiagnostic }; + +/** Async apply/reconcile boundaries translate admission timeout into the existing refusal. */ +export async function withFirstPartySecretsSettled(configDir: string, prepare: () => T): + Promise> { + try { return await withConfigDirHardeningSettled(configDir, prepare); } + catch (error) { + if (!(error instanceof WindowsSecretAclSettleTimeoutError)) throw error; + return { ok: false, reason: "ca_unavailable", path: claudeInterceptCaCertPath(configDir), + diagnostic: { stage: "ca", cause: "ETIMEDOUT" } }; + } +} /** * Write the first-party env into Claude Code's settings. Creates the local CA first so the @@ -226,11 +253,15 @@ export function applyDesktopFirstParty( const opencodexConfigDir = options.opencodexConfigDir ?? getConfigDir(); if (!claudeInterceptEnabled(config)) return { ok: false, reason: "intercept_disabled", path: "" }; let target: DesktopFirstPartyTarget; + let stage: InterceptSecretDiagnostic["stage"] = "ca"; try { ensureLocalInterceptCa(opencodexConfigDir); + stage = "token"; target = desktopFirstPartyTarget(config, opencodexConfigDir); - } catch { - return { ok: false, reason: "ca_unavailable", path: claudeInterceptCaCertPath(opencodexConfigDir) }; + } catch (error) { + const diagnostic = { stage, cause: secretFailureCause(error) }; + console.warn(`[claude-intercept] secret preparation failed: stage=${diagnostic.stage} cause=${diagnostic.cause}`); + return { ok: false, reason: "ca_unavailable", path: claudeInterceptCaCertPath(opencodexConfigDir), diagnostic }; } const written = applyClaudeInterceptSettings(target.env, options.claudeConfigDir); if (!written.ok) return { ok: false, reason: written.reason, path: written.path }; @@ -248,3 +279,22 @@ export function removeDesktopFirstParty( const caCertPath = claudeInterceptCaCertPath(options.opencodexConfigDir ?? getConfigDir()); return removeClaudeInterceptSettings(caCertPath, options.claudeConfigDir); } + +/** + * Remove Desktop's first-party env once ACL work overlapping the Claude settings home settles. + * A `CLAUDE_CONFIG_DIR` nested in the opencodex home shares the config-root hardening flight, so + * the credential-bearing settings rewrite gets the same admission as other intercept secrets. + * The config is read inside the same synchronous turn. A settle timeout refuses without writing. + */ +export async function removeDesktopFirstPartySettled( + readConfig: () => Pick, + options: DesktopFirstPartyOptions = {}, + remove: typeof removeDesktopFirstParty = removeDesktopFirstParty, +): Promise> { + const dir = options.claudeConfigDir ?? claudeConfigDir(); + try { return await withConfigDirHardeningSettled(dir, () => remove(readConfig(), options)); } + catch (error) { + if (!(error instanceof WindowsSecretAclSettleTimeoutError)) throw error; + return { ok: false, reason: "unreadable", path: join(dir, "settings.json") }; + } +} diff --git a/src/claude/desktop-picker.ts b/src/claude/desktop-picker.ts index 6c00058c423..e804938bb01 100644 --- a/src/claude/desktop-picker.ts +++ b/src/claude/desktop-picker.ts @@ -6,7 +6,7 @@ */ import type { OcxConfig } from "../types"; import { claudeDesktopIntegrationEnabled } from "../codex/desired-state"; -import { getConfigDir } from "../config/paths"; +import { getConfigDir, withConfigDirHardeningSettled } from "../config/paths"; import { readFileSync, existsSync } from "node:fs"; import { acceptsPickerAuthority, pickerCaCertPath, pickerCaFingerprints, ensurePickerCa, issuePickerLeaf, pickerLeafCertPath, publishedPickerCaSha256 } from "./intercept/picker-ca"; import { inspectPickerTrust, trustPickerCa, untrustPickerCa } from "./intercept/picker-trust"; @@ -222,7 +222,7 @@ export function createDesktopPickerController(deps: DesktopPickerControllerDeps) if (first) return refuse(first); if (options.persist) { try { - if (!deps.persistPreference(true)) return refuse("mode_not_committed"); + if (!await withConfigDirHardeningSettled(deps.configDir, () => deps.persistPreference(true))) return refuse("mode_not_committed"); } catch { return refuse("mode_not_committed"); } } const second = check(deps.readConfig(), true); @@ -237,12 +237,12 @@ export function createDesktopPickerController(deps: DesktopPickerControllerDeps) try { const ca = deps.persistentAuthority ? await preparePersistentPickerAuthority({ configDir: deps.configDir, store: deps.persistentAuthority.store, security: deps.security, platform }) - : ensurePickerCa(deps.configDir); + : await withConfigDirHardeningSettled(deps.configDir, () => ensurePickerCa(deps.configDir)); // The CLI path gates trust on the same profile check; a published root whose bytes do not // match the profile this process mints must not reach the keychain through the server // path either, even when a live peer published it. if (!acceptsPickerAuthority(ca.certPem)) return refuse("ca_unverified"); - issuePickerLeaf(ca, deps.configDir); + await withConfigDirHardeningSettled(deps.configDir, () => issuePickerLeaf(ca, deps.configDir)); caPath = pickerCaCertPath(deps.configDir); caSha1 = pickerCaFingerprints(ca.certPem).sha1; let trust = await inspectPickerTrust(pickerLeafCertPath(deps.configDir), caSha1, deps.security, platform); @@ -268,7 +268,10 @@ export function createDesktopPickerController(deps: DesktopPickerControllerDeps) const compensationFailed = (trustedByAttempt || options.callerAddedTrust === true) && !(await compensateTrust(true)); return withTrustFailure({ ...runtimeStatus(trust), reason: "proxy_unavailable" }, compensationFailed); } - const applied = applyProfile({ ...profileOptions(deps), proxyPort }); + const applied = await withConfigDirHardeningSettled(deps.configDir, () => + check(deps.readConfig(), true) || deps.proxyPort() !== proxyPort + ? { ok: false as const, reason: "write_failed" } + : applyProfile({ ...profileOptions(deps), proxyPort })); if (!applied.ok) { const compensationFailed = (trustedByAttempt || options.callerAddedTrust === true) && !(await compensateTrust(true)); return withTrustFailure({ ...runtimeStatus(trust), reason: "profile_failed", residual: ["profile"] }, compensationFailed); @@ -288,11 +291,11 @@ export function createDesktopPickerController(deps: DesktopPickerControllerDeps) const residual: string[] = []; try { deps.runtime.disarm(); } catch { residual.push("runtime"); } if (options.persist) { - try { if (!deps.persistPreference(false)) residual.push("preference"); } + try { if (!await withConfigDirHardeningSettled(deps.configDir, () => deps.persistPreference(false))) residual.push("preference"); } catch { residual.push("preference"); } } try { - const removed = removeProfile(profileOptions(deps)); + const removed = await withConfigDirHardeningSettled(deps.configDir, () => removeProfile(profileOptions(deps))); if (!removed.ok) residual.push("profile"); } catch { residual.push("profile"); } // Trust goes only once Desktop no longer selects the picker profile: a selected profile without @@ -332,7 +335,7 @@ export async function removeDesktopPickerArtifacts(options: { configDir?: string const platform = options.platform ?? process.platform; const residual: string[] = []; try { - const removed = removeDesktopPickerProfile({ configDir, ...(platform ? { platform } : {}) }); + const removed = await withConfigDirHardeningSettled(configDir, () => removeDesktopPickerProfile({ configDir, ...(platform ? { platform } : {}) })); if (!removed.ok) residual.push("profile"); } catch { residual.push("profile"); } let released = false; diff --git a/src/claude/first-party-settings.ts b/src/claude/first-party-settings.ts index 520bed56bb7..b8506d35561 100644 --- a/src/claude/first-party-settings.ts +++ b/src/claude/first-party-settings.ts @@ -9,6 +9,7 @@ import { resolveClaudeDesktopMode, type ClaudeDesktopModeObservation, type DesktopFirstPartyOptions, + type DesktopFirstPartyApplyResult, } from "./desktop-first-party"; import { claudeInterceptCaCertPath } from "./intercept/local-ca"; import { invalidateClaudeCodeServedCatalog } from "./intercept/cli-catalog"; @@ -39,8 +40,9 @@ export function firstPartyDesired( export type ClaudeFirstPartyReconcileResult = | { ok: true; action: "applied" | "removed" | "unchanged"; changed: boolean; path: string } - | { ok: false; reason: "intercept_disabled" | "ca_unavailable" | "unreadable" | "foreign_env"; path: string }; + | Extract; +// Synchronous leaf: async callers wrap reconciliation in withConfigDirHardeningSettled. export function reconcileClaudeFirstPartySettings( config: Pick, desired: ClaudeFirstPartyDesired, diff --git a/src/claude/intercept/local-ca.ts b/src/claude/intercept/local-ca.ts index b4a2eb54fd0..b6d7d6004b7 100644 --- a/src/claude/intercept/local-ca.ts +++ b/src/claude/intercept/local-ca.ts @@ -1,5 +1,6 @@ import { createHash, generateKeyPairSync, createPrivateKey, createPublicKey, sign, X509Certificate, type KeyObject } from "node:crypto"; import { join } from "node:path"; +import { withConfigDirHardeningSettled } from "../../config/paths"; import { withLocalCaPublication, type LocalCaFiles } from "./local-ca-files"; /** @@ -371,7 +372,7 @@ export function ensureLocalInterceptCa(configDir: string): LocalInterceptCa { * contention, before binding listeners, without blocking the main event loop. */ export async function ensureLocalInterceptCaForStartup(configDir: string): Promise { for (let attempt = 0; ; attempt += 1) { - try { return ensureLocalInterceptCa(configDir); } + try { return await withConfigDirHardeningSettled(configDir, () => ensureLocalInterceptCa(configDir)); } catch (error) { if (attempt >= 49 || !(error instanceof Error) || !("code" in error) || error.code !== "client_lifecycle_busy") throw error; diff --git a/src/claude/intercept/picker-ca-cleanup.ts b/src/claude/intercept/picker-ca-cleanup.ts index f2b01cc7134..8c01f8e34a6 100644 --- a/src/claude/intercept/picker-ca-cleanup.ts +++ b/src/claude/intercept/picker-ca-cleanup.ts @@ -1,6 +1,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { withConfigDirHardeningSettled } from "../../config/paths"; import { acknowledgePendingPickerCaUntrust, pendingPickerCaHasLivePublishedOwner, @@ -16,7 +17,7 @@ export async function drainPendingPickerCaUntrust( ): Promise { const pending = readPendingPickerCaUntrust(configDir); if (!pending) return true; - if (pendingPickerCaHasLivePublishedOwner(configDir, pending)) return false; + if (await withConfigDirHardeningSettled(configDir, () => pendingPickerCaHasLivePublishedOwner(configDir, pending))) return false; const privateDir = mkdtempSync(join(tmpdir(), "ocx-picker-untrust-")); try { @@ -24,7 +25,8 @@ export async function drainPendingPickerCaUntrust( writeFileSync(publicCopy, pending.certPem, { mode: 0o600 }); const result = await untrustPickerCa(publicCopy, pending.sha1, security, platform); if (!result.ok) return false; - return acknowledgePendingPickerCaUntrust(configDir, pending, result); + // OS untrust yielded: a new parent flight may have started since the owner inspection. + return await withConfigDirHardeningSettled(configDir, () => acknowledgePendingPickerCaUntrust(configDir, pending, result)); } finally { rmSync(privateDir, { recursive: true, force: true }); } diff --git a/src/claude/intercept/picker-ca-startup.ts b/src/claude/intercept/picker-ca-startup.ts index 63d1a9f292b..2967c48e8c4 100644 --- a/src/claude/intercept/picker-ca-startup.ts +++ b/src/claude/intercept/picker-ca-startup.ts @@ -1,4 +1,5 @@ import { ensurePickerCa, type PickerCa } from "./picker-ca"; +import { withConfigDirHardeningSettled } from "../../config/paths"; import { drainPendingPickerCaUntrust } from "./picker-ca-cleanup"; import type { PickerCaStore } from "./picker-ca-store"; import type { SecurityRunner } from "./picker-trust"; @@ -11,7 +12,8 @@ export async function preparePersistentPickerAuthority(options: { if (!supportsDesktopPicker(options.platform)) throw new Error("picker_ca_unsupported_platform"); const drain = () => drainPendingPickerCaUntrust(options.configDir, options.security, options.platform); if (!await drain()) throw new Error("picker_ca_pending_untrust"); - const ca = ensurePickerCa(options.configDir, { rotation: "startup", persistent: true, store: options.store }); + const ca = await withConfigDirHardeningSettled(options.configDir, () => + ensurePickerCa(options.configDir, { rotation: "startup", persistent: true, store: options.store })); if (!await drain()) throw new Error("picker_ca_pending_untrust"); return ca; } diff --git a/src/claude/intercept/picker-models.ts b/src/claude/intercept/picker-models.ts index 17ae9c3d9f9..c704f6d4f63 100644 --- a/src/claude/intercept/picker-models.ts +++ b/src/claude/intercept/picker-models.ts @@ -4,6 +4,7 @@ */ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; +import { withConfigDirHardeningSettled } from "../../config/paths"; import { nativeOpenAiContextWindow, type NativeContextLimitsInput } from "../../codex/catalog"; import type { OcxClaudeDesktopProfile } from "../../types"; import { aliasForRoute, claudeCodeNativeAlias } from "../alias"; @@ -137,9 +138,11 @@ export function createPickerModelSnapshot( const models = build(await load()); const next = { models, builtAt: Date.now() }; if (persistPath) { - mkdirSync(dirname(persistPath), { recursive: true, mode: 0o700 }); - writeFileSync(persistPath, JSON.stringify(next), { encoding: "utf8", mode: 0o600 }); - chmodSync(persistPath, 0o600); + await withConfigDirHardeningSettled(dirname(persistPath), () => { + mkdirSync(dirname(persistPath), { recursive: true, mode: 0o700 }); + writeFileSync(persistPath, JSON.stringify(next), { encoding: "utf8", mode: 0o600 }); + chmodSync(persistPath, 0o600); + }); } snapshot = next; } catch { /* Keep the last good snapshot when discovery or persistence fails. */ } diff --git a/src/claude/intercept/picker-runtime.ts b/src/claude/intercept/picker-runtime.ts index 7b4f56310e3..2c47c84fec0 100644 --- a/src/claude/intercept/picker-runtime.ts +++ b/src/claude/intercept/picker-runtime.ts @@ -15,6 +15,7 @@ */ import { join } from "node:path"; import type { OcxConfig } from "../../types"; +import { withConfigDirHardeningSettled } from "../../config/paths"; import type { ClaudeDesktopMode } from "../desktop-first-party"; import { supportsDesktopPicker } from "../desktop-picker-platform"; import type { TunnelDecision } from "./connect-proxy"; @@ -170,8 +171,8 @@ export function createPickerRuntime(options: CreatePickerRuntimeOptions): Picker if (!ca || !leaf || !caSha1) { ca = options.persistentAuthority ? await preparePersistentPickerAuthority({ configDir: options.configDir, store: options.persistentAuthority.store, security: options.security, platform }) - : ensurePickerCa(options.configDir); - leaf = issuePickerLeaf(ca, options.configDir); + : await withConfigDirHardeningSettled(options.configDir, () => ensurePickerCa(options.configDir)); + leaf = await withConfigDirHardeningSettled(options.configDir, () => issuePickerLeaf(ca!, options.configDir)); caSha1 = pickerCaFingerprints(ca.certPem).sha1; } return { ca, leaf, sha1: caSha1 }; diff --git a/src/claude/intercept/runtime.ts b/src/claude/intercept/runtime.ts index 4e6866731a0..6c5001e41b8 100644 --- a/src/claude/intercept/runtime.ts +++ b/src/claude/intercept/runtime.ts @@ -1,7 +1,8 @@ import type { Server } from "bun"; import { supportsDesktopPicker } from "../desktop-picker-platform"; import type { OcxConfig } from "../../types"; -import { getConfigDir } from "../../config/paths"; +import { getConfigDir, withConfigDirHardeningSettled } from "../../config/paths"; +import { claudeConfigDir } from "../auth-detect"; import type { DesktopPickerController } from "../desktop-picker"; import type { ClaudeFirstPartyDesired } from "../first-party-settings"; import { classifyInterceptClient, interceptRouteFor } from "./client-class"; @@ -149,7 +150,7 @@ export async function startClaudeIntercept(options: StartClaudeInterceptOptio const configDir = options.configDir ?? getConfigDir(); const ca = await ensureLocalInterceptCaForStartup(configDir); const startProxy = options.startProxy ?? startConnectProxy; - const authToken = ensureClaudeInterceptProxyToken(configDir); + const authToken = await withConfigDirHardeningSettled(configDir, () => ensureClaudeInterceptProxyToken(configDir)); const leaf = issueLocalInterceptLeaf(ca, CLAUDE_INTERCEPT_HOSTS); const listener = startClaudeInterceptListener({ leaf, @@ -179,7 +180,9 @@ export async function startClaudeIntercept(options: StartClaudeInterceptOptio // fixed-port bind leaves settings untouched; an ephemeral test binder never publishes :0 // or a probed port another process can steal. Only stale, owned env blocks are rewritten. try { - migrateClaudeInterceptSettings(buildClaudeInterceptEnv(proxy.port, claudeInterceptCaCertPath(configDir), authToken)); + // Fresh admission after the bind await: a nested CLAUDE_CONFIG_DIR shares the config-root flight. + await withConfigDirHardeningSettled(claudeConfigDir(), () => + migrateClaudeInterceptSettings(buildClaudeInterceptEnv(proxy.port, claudeInterceptCaCertPath(configDir), authToken))); } catch (error) { // Ensure/apply retries a skipped migration; upgraded clients otherwise keep hitting 407. console.warn(`[claude-intercept] settings migration skipped: ${error instanceof Error ? error.message : String(error)}`); diff --git a/src/cli/claude-desktop.ts b/src/cli/claude-desktop.ts index f10c6008125..be66a45239c 100644 --- a/src/cli/claude-desktop.ts +++ b/src/cli/claude-desktop.ts @@ -1,7 +1,7 @@ import { recordCommittedDesktopGateway } from "../claude/desktop-gateway-state"; import { readFileSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; -import { getConfigDir } from "../config/paths"; +import { getConfigDir, withConfigDirHardeningSettled } from "../config/paths"; import { loadConfig, mutatePersistedConfig, withConfigMutationLockSync } from "../config"; import { claudeDesktopIntegrationEnabledNow, setIntegrationEnabled } from "../codex/desired-state"; import { readClientConnectionState, assertClientConnectionUnchanged, assertNoClientDisconnectPending, type ClientConnectionState } from "../client/state"; @@ -19,12 +19,12 @@ import { } from "../claude/desktop-profile"; import { inspectDesktop3pConfigLibrary, removeDesktop3pStandardPivot, writeDesktop3pConfig, type Desktop3pConfigMode, parseDesktop3pModeArgs } from "../claude/desktop-3p"; import { - applyDesktopFirstParty, + applyDesktopFirstParty, withFirstPartySecretsSettled, captureDesktopFirstPartyRollback, isClaudeDesktopMode, observeClaudeDesktopMode, recordClaudeDesktopMode, - removeDesktopFirstParty, + removeDesktopFirstParty, removeDesktopFirstPartySettled, resolveClaudeDesktopMode, resolveClaudeDesktopApplyMode, type ClaudeDesktopModeObservation, @@ -99,6 +99,7 @@ type DesktopApplyResult = { warning?: string; picker?: DesktopPickerStatus; delegated?: boolean; + diagnostic?: import("../claude/desktop-first-party").InterceptSecretDiagnostic; }; type PickerRouteResponse = { @@ -155,7 +156,7 @@ async function trustPickerLocally( ): Promise<{ ok: true; callerAddedTrust: boolean; caPath: string; sha1: string } | { ok: false; reason: string }> { try { const configDir = getConfigDir(); - const paths = pickerTrustPaths(deps, configDir); + const paths = await withConfigDirHardeningSettled(configDir, () => pickerTrustPaths(deps, configDir)); if (!acceptsPickerAuthority(paths.certPem)) return { ok: false, reason: "ca_unverified" }; if (!deps.ensurePickerCaImpl) { // The bytes in ca.pem are only safe to trust when they match the authority the running @@ -426,14 +427,14 @@ async function applyFirstPartyDesktop( // Establish the replacement before deleting the working gateway. A refused // cleanup restores only our managed env keys, preserving unrelated settings. const rollback = captureDesktopFirstPartyRollback(config); - const applied = applyDesktopFirstParty(config); - if (!applied.ok) return { ok: false, path: applied.path, reason: applied.reason }; + const applied = await withFirstPartySecretsSettled(getConfigDir(), () => applyDesktopFirstParty(config)); + if (!applied.ok) return { ok: false, path: applied.path, reason: applied.reason, ...(applied.diagnostic ? { diagnostic: applied.diagnostic } : {}) }; const appliedFingerprint = config.claudeCode?.desktopProfile?.appliedFingerprint ?? null; const library = inspectDesktop3pConfigLibrary({ appliedFingerprint }); if (library.kind === "gateway_ours" || library.kind === "gateway_drifted") { const removed = removeDesktop3pStandardPivot({ appliedFingerprint, replaceWhileEnabled: true }); if (!removed.ok) { - const restored = removed.changed || !applied.changed || rollback(); + const restored = removed.changed || !applied.changed || await withConfigDirHardeningSettled(getConfigDir(), rollback); const modeSaved = !removed.changed || saveDesktopMode("first-party", deps); const warning = [restored ? "" : "first-party settings rollback did not complete", modeSaved ? "" : "first-party is active but its mode marker was not saved"].filter(Boolean).join("; "); @@ -462,7 +463,7 @@ export async function applyDesktop( const modeSaved = saveDesktopMode("gateway", deps); const warning = [result.warning, modeSaved ? "" : "desktop mode marker was not saved"].filter(Boolean).join(" "); // The gateway mode is committed before retiring first-party settings. - const removed = removeDesktopFirstParty(loadConfig()); + const removed = await removeDesktopFirstPartySettled(loadConfig); if (!removed.ok) return { ok: false, path: removed.path, reason: "first_party_settings_unreadable", warning: ["gateway applied; first-party cleanup remains incomplete", warning].filter(Boolean).join(" ") }; if (removed.retainedFor === "cli") { diff --git a/src/cli/ensure-desired-integrations.ts b/src/cli/ensure-desired-integrations.ts index 9cff9cf11f5..3299983fa73 100644 --- a/src/cli/ensure-desired-integrations.ts +++ b/src/cli/ensure-desired-integrations.ts @@ -10,6 +10,7 @@ */ import { loadConfig } from "../config"; import { cliFirstPartyDesired, firstPartyDesired, reconcileClaudeFirstPartySettings } from "../claude/first-party-settings"; +import { getConfigDir } from "../config/paths"; import { claudeInterceptEnabled } from "../claude/intercept/runtime"; import { removeDesktopPickerArtifacts } from "../claude/desktop-picker"; import { findLiveProxy } from "../server/proxy-liveness"; @@ -17,10 +18,10 @@ import { runtimeRequest } from "./runtime-api"; import { stripGrokConfig, type GrokInjectResult } from "../grok/inject"; import { inspectDesktop3pConfigLibrary, removeDesktop3pStandardPivot } from "../claude/desktop-3p"; import { - applyDesktopFirstParty, + applyDesktopFirstParty, withFirstPartySecretsSettled, inspectDesktopFirstParty, observeClaudeDesktopMode, - removeDesktopFirstParty, + removeDesktopFirstParty, removeDesktopFirstPartySettled, resolveClaudeDesktopMode, } from "../claude/desktop-first-party"; import { @@ -154,8 +155,9 @@ export async function ensureClaudeDesktopMatchesDesired( if (cliFirstPartyDesired(config)) { const seen = (deps.inspectDesktopFirstParty ?? inspectDesktopFirstParty)(config); if (seen.settings.kind === "absent" || seen.stale || !claudeInterceptEnabled(config)) { - const result = (deps.reconcileClaudeFirstPartySettings ?? reconcileClaudeFirstPartySettings)(config, - firstPartyDesired(config, (deps.observeClaudeDesktopMode ?? observeClaudeDesktopMode)(config))); + const result = await withFirstPartySecretsSettled(getConfigDir(), () => + (deps.reconcileClaudeFirstPartySettings ?? reconcileClaudeFirstPartySettings)(config, + firstPartyDesired(config, (deps.observeClaudeDesktopMode ?? observeClaudeDesktopMode)(config)))); if (result.ok && result.changed) log(` + Claude CLI first-party env refreshed (${result.path})`); else if (!result.ok) error(`⚠️ Claude CLI first-party env refresh skipped: ${result.reason}.`); } @@ -173,7 +175,7 @@ export async function ensureClaudeDesktopMatchesDesired( } const seen = (deps.inspectDesktopFirstParty ?? inspectDesktopFirstParty)(config); if (!seen.stale) return; - const applied = (deps.applyDesktopFirstParty ?? applyDesktopFirstParty)(config); + const applied = await withFirstPartySecretsSettled(getConfigDir(), () => (deps.applyDesktopFirstParty ?? applyDesktopFirstParty)(config)); if (applied.ok && applied.changed) log(` + Claude Desktop first-party env refreshed (${applied.path})`); else if (!applied.ok) error(`⚠️ Claude Desktop first-party env refresh skipped: ${applied.reason}.`); return; @@ -196,7 +198,7 @@ export async function ensureClaudeDesktopMatchesDesired( error(`⚠️ Claude Desktop picker cleanup skipped: ${detail}.`); } try { - const env = (deps.removeDesktopFirstParty ?? removeDesktopFirstParty)(deps.loadConfig()); + const env = await removeDesktopFirstPartySettled(deps.loadConfig, {}, deps.removeDesktopFirstParty ?? removeDesktopFirstParty); if (env.ok && env.changed) log(" ↩️ Claude Desktop first-party env removed."); else if (env.ok && env.retainedFor === "cli") log(" = Shared first-party env retained for Claude Code CLI."); else if (!env.ok) error(`⚠️ Claude Desktop first-party env cleanup skipped: ${env.reason} (${env.path}).`); diff --git a/src/config/paths.ts b/src/config/paths.ts index d20166733d8..9f470b9ebd4 100644 --- a/src/config/paths.ts +++ b/src/config/paths.ts @@ -1,7 +1,7 @@ import { chmodSync, existsSync } from "node:fs"; import { homedir } from "node:os"; import { join, resolve } from "node:path"; -import { flushWindowsSecretAclReapsBeforeRemoval, hardenSecretDirAsync, windowsSecretAclApplies } from "../lib/windows-secret-acl"; +import { flushWindowsSecretAclReapsBeforeRemoval, hardenSecretDirAsync, windowsSecretAclApplies, withWindowsSecretAclSettled } from "../lib/windows-secret-acl"; import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; import { isPassiveRead } from "../lib/passive-read"; import { trackBackgroundWork } from "../lib/background-work"; @@ -73,6 +73,21 @@ export async function flushConfigDirHardening(dir: string = getConfigDir()): Pro if (flight) await flight; } +/** + * Intercept CA/token/picker writers call this after their last unrelated await. Parent + * inheritable ACL propagation can overwrite a child's concurrent explicit grant. Drain the + * registered config flight and overlapping work (including ancestors and timeout reaps), + * within one ACL deadline. Timeout refuses admission without dropping child cleanup ownership. + * The final registry check and synchronous callback share a turn: promise continuations and + * newly queued hardeners cannot start icacls between that check and secret publication. + * Keep the callback synchronous; an await inside it would reopen the propagation race. + */ +export async function withConfigDirHardeningSettled(dir: string, mutate: () => T): Promise { + // Async work registers before its factory starts, including the optional config flight. + // A separate flush here would escape the registry's total admission deadline. + return withWindowsSecretAclSettled(dir, mutate); +} + /** A settled ACL deadline is not proof its child has exited; stop must drain both before removal. */ export async function flushConfigDirHardeningAndReaps(dir: string): Promise { try { await flushConfigDirHardening(dir); } diff --git a/src/lib/windows-secret-acl.ts b/src/lib/windows-secret-acl.ts index 050acddb9a2..5841a656fb7 100644 --- a/src/lib/windows-secret-acl.ts +++ b/src/lib/windows-secret-acl.ts @@ -493,7 +493,7 @@ function pathIsAtOrBelow(target: string, root: string): boolean { return suffix !== ".." && !suffix.startsWith(`..${sep}`) && !isAbsolute(suffix); } -function retainedWorkIsAtOrBelow(entry: AsyncAclWork, root: RemovalIdentity): boolean { +function retainedWorkIsAtOrBelow(entry: AsyncAclWork, root: RemovalIdentity, includeAncestors = false): boolean { const target = entry.identity; // Failure at capture stays conservative, even if the alias later becomes readable. if (target.canonical === undefined || root.canonical === undefined) return true; @@ -501,7 +501,43 @@ function retainedWorkIsAtOrBelow(entry: AsyncAclWork, root: RemovalIdentity): bo if (current === undefined) return true; const targets = [target.lexical, target.canonical, current]; return [root.lexical, root.canonical].some(rootPath => - targets.some(targetPath => pathIsAtOrBelow(targetPath, rootPath))); + targets.some(targetPath => pathIsAtOrBelow(targetPath, rootPath) + || (includeAncestors && pathIsAtOrBelow(rootPath, targetPath)))); +} + +/** Admission can refuse without surrendering the registry's retained-child cleanup ownership. */ +export class WindowsSecretAclSettleTimeoutError extends Error { + readonly code = "ETIMEDOUT"; + readonly aclFailureOrigin = "acl_settle_timeout"; + constructor() { super("ACL settlement timed out"); this.name = "WindowsSecretAclSettleTimeoutError"; } +} + +/** Run a synchronous mutation after overlapping ACL work exits, within one harden budget. */ +export async function withWindowsSecretAclSettled(rootPath: string, mutate: () => T): Promise { + const deadline = nowFn() + resolveHardenDeadlineMs(); + const root = captureRemovalIdentity(rootPath); + let cancelDeadline: (() => void) | undefined; + let timeout: Promise | undefined; + let expired = false; + try { + while (true) { + if (expired || nowFn() >= deadline) throw new WindowsSecretAclSettleTimeoutError(); + const pending = [...activeAsyncAclWork.values(), ...pendingAsyncIcaclsReaps.values()] + .flatMap(entries => [...entries]) + .filter(entry => retainedWorkIsAtOrBelow(entry, root, true)) + .map(entry => entry.reap); + // No await between this final observation and the mutation. A newly queued hardener's + // microtasks cannot run icacls until the synchronous callback has completely returned. + if (pending.length === 0) return mutate(); + timeout ??= new Promise((_resolve, reject) => { + cancelDeadline = asyncIcaclsBeltScheduler(() => { + expired = true; + reject(new WindowsSecretAclSettleTimeoutError()); + }, Math.max(1, deadline - nowFn())); + }); + await Promise.race([Promise.all(pending), timeout]); + } + } finally { cancelDeadline?.(); } } /** True while an async icacls runner still owns this exact path after its caller's belt fired. */ @@ -550,7 +586,7 @@ export function setAsyncIcaclsRunnerForTests(runner: AsyncIcaclsRunner | null): asyncIcaclsRunner = runner ?? defaultAsyncIcaclsRunner; } -/** Test seam: fire the outer caller-facing belt without sleeping. */ +/** Test seam: drive child belts and admission deadlines without sleeping. */ export function setAsyncIcaclsBeltSchedulerForTests( scheduler: SubprocessDeadlineScheduler | null, ): void { diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index 8ea546b1177..89cb75d2d6a 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -1062,7 +1062,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); } - const { resolveClaudeDesktopApplyMode, observeClaudeDesktopMode, applyDesktopFirstParty, captureDesktopFirstPartyRollback, removeDesktopFirstParty } = await import("../../claude/desktop-first-party"); + const { resolveClaudeDesktopApplyMode, observeClaudeDesktopMode, applyDesktopFirstParty, withFirstPartySecretsSettled, captureDesktopFirstPartyRollback, removeDesktopFirstPartySettled } = await import("../../claude/desktop-first-party"); const requested = (parsed as { mode?: unknown } | null)?.mode; let desktopMode: "first-party" | "gateway" = resolveClaudeDesktopApplyMode(config, observeClaudeDesktopMode(config)); if (requested !== undefined) { @@ -1105,7 +1105,8 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise return await runPickerTransition(config, async ops => { const { inspectDesktop3pConfigLibrary, removeDesktop3pStandardPivot } = await import("../../claude/desktop-3p"); const rollback = captureDesktopFirstPartyRollback(config); - const applied = applyDesktopFirstParty(config); + const { getConfigDir, withConfigDirHardeningSettled } = await import("../../config/paths"); + const applied = await withFirstPartySecretsSettled(getConfigDir(), () => applyDesktopFirstParty(config)); if (!applied.ok) { const { firstPartyRefusalMessage } = await import("./native-integration-routes"); return jsonResponse({ @@ -1113,6 +1114,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise code: "claude_desktop_first_party_refused", reason: applied.reason, path: applied.path, + ...(applied.diagnostic ? { diagnostic: applied.diagnostic } : {}), }, applied.reason === "foreign_env" || applied.reason === "intercept_disabled" ? 409 : 500); } // Remove the gateway only after the replacement env was written. @@ -1122,7 +1124,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise if (library.kind === "gateway_ours" || library.kind === "gateway_drifted") { const removed = (deps.removeDesktop3pStandardPivot ?? removeDesktop3pStandardPivot)({ appliedFingerprint, replaceWhileEnabled: true }); if (!removed.ok) { - const restored = removed.changed || !applied.changed || rollback(); + const restored = removed.changed || !applied.changed || await withConfigDirHardeningSettled(getConfigDir(), rollback); const modeSaved = !removed.changed || (await persistDesktopModeField(config, "first-party")).ok; const warning = [restored ? "" : "first-party settings rollback did not complete", modeSaved ? "" : "first-party is active but its mode marker was not saved"].filter(Boolean).join("; "); @@ -1219,10 +1221,10 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise const committed = persistCommittedDesktopGateway(config, state.profile, result.fingerprint); const modeWarning = committed.ok ? undefined : `Gateway applied, but its mode/profile state was not saved (${committed.reason}).`; // The durable marker describes the committed gateway even if old-mode cleanup fails. - const firstPartyRemoved = removeDesktopFirstParty(loadConfig()); + const firstPartyRemoved = await removeDesktopFirstPartySettled(loadConfig); if (!firstPartyRemoved.ok) { return { response: jsonResponse({ - error: `Claude Code settings could not be parsed (${firstPartyRemoved.path}); first-party cleanup remains incomplete after gateway apply.`, + error: `Claude Code first-party settings cleanup did not complete (${firstPartyRemoved.path}); the gateway was applied.`, code: "claude_desktop_first_party_refused", reason: firstPartyRemoved.reason, applied: true, saved: committed.ok, mode: "gateway", path: result.path, ...(modeWarning ? { warning: modeWarning } : {}), @@ -1407,7 +1409,10 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise !("fingerprint" in body) || typeof body.fingerprint !== "string" || !/^[a-f0-9]{64}$/.test(body.fingerprint)) { return jsonResponse({ error: "fingerprint is required" }, 400); } - return jsonResponse(applyClaudeModelDefaults(config.claudeCode?.tierModels, body.fingerprint)); + // Fresh admission after the body await: a nested Claude home shares the config-root ACL flight. + const fingerprint = body.fingerprint; + const { withConfigDirHardeningSettled } = await import("../../config/paths"), { claudeConfigDir } = await import("../../claude/auth-detect"); + return jsonResponse(await withConfigDirHardeningSettled(claudeConfigDir(), () => applyClaudeModelDefaults(config.claudeCode?.tierModels, fingerprint))); } catch (error) { rethrowManagementBodyTooLarge(error); if (error instanceof ModelDefaultsRecoveryError) return jsonResponse({ error: error.message, code: error.message, backupPath: error.backupPath }, 500); @@ -1552,7 +1557,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise return jsonResponse({ error: "cliFirstParty must be sent alone", code: "cli_first_party_not_alone" }, 400); if (body.cliFirstParty !== undefined) { const { claudeInterceptEnabled, claudeInterceptProxyPort, getClaudeInterceptState } = await import("../../claude/intercept/runtime"); - const { captureDesktopFirstPartyRollback, inspectDesktopFirstParty, observeClaudeDesktopMode, resolveClaudeDesktopMode } = await import("../../claude/desktop-first-party"); + const { captureDesktopFirstPartyRollback, inspectDesktopFirstParty, observeClaudeDesktopMode, resolveClaudeDesktopMode, withFirstPartySecretsSettled } = await import("../../claude/desktop-first-party"); const { firstPartyDesired, readFirstPartyProxyStatus, reconcileClaudeFirstPartySettings } = await import("../../claude/first-party-settings"); const { commitClaudeCodeBlock } = await import("../../claude/claude-code-block"); let bound = (deps.getClaudeInterceptState ?? getClaudeInterceptState)(); @@ -1622,13 +1627,15 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise if (committed.pinnedMode) live.desktopMode = committed.pinnedMode; config.claudeCode = live; let result: ReturnType | undefined; - try { result = (deps.reconcileClaudeFirstPartySettings ?? reconcileClaudeFirstPartySettings)(config, - firstPartyDesired(config, observeClaudeDesktopMode(config))); } + const { getConfigDir, withConfigDirHardeningSettled } = await import("../../config/paths"); + try { result = await withFirstPartySecretsSettled(getConfigDir(), () => + (deps.reconcileClaudeFirstPartySettings ?? reconcileClaudeFirstPartySettings)(config, + firstPartyDesired(config, observeClaudeDesktopMode(config)))); } catch { /* An unexpected settings failure is mapped after conditional rollback. */ } if (!result?.ok) { let settingsRestored = true; if (body.cliFirstParty) { - try { settingsRestored = restoreSettings?.() ?? false; } catch { settingsRestored = false; } + try { settingsRestored = result?.reason === "ca_unavailable" || await withConfigDirHardeningSettled(getConfigDir(), () => restoreSettings?.() ?? false); } catch { settingsRestored = false; } let rollback: ReturnType>; try { rollback = mutatePersistedConfig(persisted => { @@ -1650,6 +1657,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise } const code = result?.reason ?? "write_failed"; return jsonResponse({ error: "Claude first-party reconciliation failed", code, + ...(result?.diagnostic ? { diagnostic: result.diagnostic } : {}), ...(!body.cliFirstParty && code === "unreadable" ? { cliFirstParty: false, warnings: ["settings_residual"] } : {}), ...(body.cliFirstParty && !settingsRestored diff --git a/src/server/management/native-integration-routes.ts b/src/server/management/native-integration-routes.ts index 4e752ceb393..88c21812f6f 100644 --- a/src/server/management/native-integration-routes.ts +++ b/src/server/management/native-integration-routes.ts @@ -30,14 +30,15 @@ import { providerContextCap } from "../../providers/context-cap"; import { OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; import { inspectDesktop3pConfigLibrary, removeDesktop3pStandardPivot, writeDesktop3pConfig } from "../../claude/desktop-3p"; import { - applyDesktopFirstParty, + applyDesktopFirstParty, withFirstPartySecretsSettled, captureDesktopFirstPartyRollback, inspectDesktopFirstParty, observeClaudeDesktopMode, recordClaudeDesktopMode, - removeDesktopFirstParty, + removeDesktopFirstParty, removeDesktopFirstPartySettled, resolveClaudeDesktopApplyMode, type ClaudeDesktopMode, + type InterceptSecretDiagnostic, } from "../../claude/desktop-first-party"; import { FIRST_PARTY_ACCOUNT_RISK } from "../../claude/desktop-risk"; import type { DesktopPickerStatus } from "../../claude/desktop-picker"; @@ -49,6 +50,7 @@ import { grokConfigPath } from "../../grok/status"; import { assertNativeTeardownOwned } from "../../integrations/native/ownership-preflight"; import type { CodexNativeRestoreResult } from "../../codex/inject"; import type { OcxConfig } from "../../types"; +import { getConfigDir, withConfigDirHardeningSettled } from "../../config/paths"; import { jsonResponse } from "../auth-cors"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; import { managementInferencePort, type ManagementContext } from "./context"; @@ -108,6 +110,7 @@ export interface NativeRefusalEnvelope { /** Present on every post-commit refusal; absent only for pre-commit refusals. */ desiredEnabled?: boolean; residualPaths?: string[]; + diagnostic?: InterceptSecretDiagnostic; } export type NativePostCommitRefusalEnvelope = Omit & { @@ -133,7 +136,7 @@ function postCommitRefusal( clientId: NativeIntegrationClientId, reason: NativeRefusalReason, message: string, - extra: Pick, + extra: Pick, ): Response { return jsonResponse({ error: status >= 500 ? "native integration change failed" : "native integration change refused", @@ -717,7 +720,7 @@ async function handleClaudeDesktopToggle(ctx: ManagementContext): Promise { const pickerOff = await ops.disableLocked({ persist: false }); - const firstPartyRemoved = removeDesktopFirstParty(loadConfig()); + const firstPartyRemoved = await removeDesktopFirstPartySettled(loadConfig); if (!firstPartyRemoved.ok) { return postCommitRefusal(409, "claude-desktop", "write_failed", `Claude Code settings could not be read (${firstPartyRemoved.path}); the first-party proxy env was left in place.`, { desiredEnabled }); @@ -755,17 +758,17 @@ async function handleClaudeDesktopToggle(ctx: ManagementContext): Promise { const rollback = captureDesktopFirstPartyRollback(current); - const applied = applyDesktopFirstParty(current); + const applied = await withFirstPartySecretsSettled(getConfigDir(), () => applyDesktopFirstParty(current)); if (!applied.ok) { const reason = applied.reason === "foreign_env" || applied.reason === "intercept_disabled" ? applied.reason : "write_failed"; return postCommitRefusal(applied.reason === "unreadable" || applied.reason === "ca_unavailable" ? 500 : 409, "claude-desktop", reason, - firstPartyRefusalMessage(applied.reason, applied.path), { desiredEnabled }); + firstPartyRefusalMessage(applied.reason, applied.path), { desiredEnabled, ...(applied.diagnostic ? { diagnostic: applied.diagnostic } : {}) }); } const library = inspectDesktop3pConfigLibrary({ appliedFingerprint: fingerprint }); let gatewayRemoved = false; if (library.kind === "gateway_ours" || library.kind === "gateway_drifted") { const removed = (ctx.deps.removeDesktop3pStandardPivot ?? removeDesktop3pStandardPivot)({ appliedFingerprint: fingerprint, replaceWhileEnabled: true }); - if (!removed.ok && !removed.changed && applied.changed && !rollback()) { + if (!removed.ok && !removed.changed && applied.changed && !await withConfigDirHardeningSettled(getConfigDir(), rollback)) { return postCommitRefusal(500, "claude-desktop", "write_failed", "Gateway cleanup and first-party settings rollback did not complete.", { desiredEnabled }); } @@ -830,7 +833,7 @@ async function handleClaudeDesktopToggle(ctx: ManagementContext): Promise/claude-intercept/` with a 0600 key; never installed into an OS trust store). CA reads and pair publication share a directory-bound SQLite lease; persisted certificates must match their private key and verify as a self-signed CA. Directory, certificate, key and lock paths reject symlinks, nonregular files and foreign ownership; pinned descriptors and exclusive unpredictable temporary files protect reads and publication. Windows verifies ownership and permits only the current user, SYSTEM and built-in Administrators in the private-file ACL, including before writing temporary key bytes; inspection or protection failure rejects the operation. Startup retries only lease contention with bounded asynchronous backoff before binding either listener. diff --git a/tests/claude-integration/claude-desktop-first-party.test.ts b/tests/claude-integration/claude-desktop-first-party.test.ts index be641968313..fd084484a7f 100644 --- a/tests/claude-integration/claude-desktop-first-party.test.ts +++ b/tests/claude-integration/claude-desktop-first-party.test.ts @@ -53,8 +53,8 @@ async function dispatch(path: string, init?: RequestInit, inputConfig: OcxConfig return { status: response!.status, body: await response!.json() as Record }; } -// Attach the response body to every status failure so a hosted-runner flake classifies -// itself (an environmental ca_unavailable, say, instead of a bare "Expected: 200 / Received: 500"). +// Attach the response body to status failures to preserve refusal codes and sanitized +// secret-preparation stage/cause diagnostics alongside the expected/actual status. function expectStatus(result: { status: number; body: unknown }, status: number): void { expect(result.status, `body=${JSON.stringify(result.body)}`).toBe(status); } diff --git a/tests/claude-integration/claude-intercept-acl-barrier.test.ts b/tests/claude-integration/claude-intercept-acl-barrier.test.ts new file mode 100644 index 00000000000..fb48d5d27ca --- /dev/null +++ b/tests/claude-integration/claude-intercept-acl-barrier.test.ts @@ -0,0 +1,551 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, sep } from "node:path"; +import * as firstParty from "../../src/claude/desktop-first-party"; +import * as localCa from "../../src/claude/intercept/local-ca"; +import * as proxyAuth from "../../src/claude/intercept/proxy-auth"; +import * as configPaths from "../../src/config/paths"; +import * as pickerTrust from "../../src/claude/intercept/picker-trust"; +import { pickerCaFingerprints, pickerCaPendingUntrustPath, pickerStateDir } from "../../src/claude/intercept/picker-ca"; +import { drainPendingPickerCaUntrust } from "../../src/claude/intercept/picker-ca-cleanup"; +import { preparePersistentPickerAuthority } from "../../src/claude/intercept/picker-ca-startup"; +import { createDesktopPickerController } from "../../src/claude/desktop-picker"; +import { createPickerModelSnapshot } from "../../src/claude/intercept/picker-models"; +import type { PickerRuntime } from "../../src/claude/intercept/picker-runtime"; +import { applyDesktop } from "../../src/cli/claude-desktop"; +import { ensureClaudeDesktopMatchesDesired } from "../../src/cli/ensure-desired-integrations"; +import { startClaudeIntercept } from "../../src/claude/intercept/runtime"; +import { startConnectProxy } from "../../src/claude/intercept/connect-proxy"; +import { flushConfigDirHardeningAndReaps, hardenConfigDir, withConfigDirHardeningSettled } from "../../src/config/paths"; +import { setLocalCaWindowsAclRunnerForTests } from "../../src/claude/intercept/local-ca-windows"; +import { readClaudeInterceptProxyToken } from "../../src/claude/intercept/proxy-auth"; +import { hardenSecretDirAsync, resetHardenedStateForTests, setAsyncIcaclsBeltSchedulerForTests, setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests, setNowForTests, setPlatformForTests, windowsSecretAclReapPendingForPath, type IcaclsResult } from "../../src/lib/windows-secret-acl"; +import { setSyntheticWindowsPrincipalForTests } from "../../src/lib/windows-user-principal"; +import { handleManagementAPI } from "../../src/server/management-api"; +import { previewClaudeModelDefaults } from "../../src/claude/model-default-settings"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const success: IcaclsResult = { success: true, exitCode: 0, timedOut: false, stdout: "" }; +const sid = "S-1-5-21-1-2-3-1001"; +// Drain every queued microtask, however many awaits the code under test adds, without waiting on the gate. +const macrotask = () => new Promise(resolve => setImmediate(resolve)); +function deferred() { + let resolve!: () => void; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} +const keys = ["OPENCODEX_HOME", "CLAUDE_CONFIG_DIR", "OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR", "OPENCODEX_ACL_VERIFY_EXISTING", "OPENCODEX_ACL_TIMEOUT_MS"] as const; +let root: string; +let previous: (string | undefined)[]; +let entrySpy: ReturnType | undefined; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-intercept-acl-barrier-")); + previous = keys.map(key => process.env[key]); + process.env.OPENCODEX_HOME = root; + process.env.CLAUDE_CONFIG_DIR = join(root, "claude"); + process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = join(root, "desktop"); + delete process.env.OPENCODEX_ACL_VERIFY_EXISTING; + process.env.OPENCODEX_ACL_TIMEOUT_MS = "1000"; + resetHardenedStateForTests(); + setPlatformForTests("win32"); + setSyntheticWindowsPrincipalForTests(`*${sid}`); + setIcaclsRunnerForTests(() => success); + setLocalCaWindowsAclRunnerForTests(() => ({ ...success, stdout: JSON.stringify({ + owner: sid, protected: true, rules: [{ sid, type: 0, rights: 2032127 }], + }) })); +}); + +test.each(["ca", "token"] as const)("first-party failure retains a sanitized %s stage and cause", stage => { + const secret = "private-fixture-value"; + const ca = spyOn(localCa, "ensureLocalInterceptCa").mockImplementation(() => { + if (stage === "ca") throw Object.assign(new Error(secret), { code: "EACCES" }); + return {} as ReturnType; + }); + const token = spyOn(proxyAuth, "ensureClaudeInterceptProxyToken").mockImplementation(() => { + throw Object.assign(new Error(secret), { code: "EACCES" }); + }); + try { + const result = firstParty.applyDesktopFirstParty({ port: 10100 }); + expect(result).toMatchObject({ ok: false, reason: "ca_unavailable", diagnostic: { stage, cause: "EACCES" } }); + expect(JSON.stringify(result)).not.toContain(secret); + } finally { ca.mockRestore(); token.mockRestore(); } +}); +afterEach(async () => { + await flushConfigDirHardeningAndReaps(root); + entrySpy?.mockRestore(); + entrySpy = undefined; + setAsyncIcaclsRunnerForTests(null); + setAsyncIcaclsBeltSchedulerForTests(null); + setNowForTests(null); + setIcaclsRunnerForTests(null); + setLocalCaWindowsAclRunnerForTests(null); + setPlatformForTests(null); + setSyntheticWindowsPrincipalForTests(null); + resetHardenedStateForTests(); + keys.forEach((key, index) => { + if (previous[index] === undefined) delete process.env[key]; + else process.env[key] = previous[index]; + }); + removeTreeWithRetry(root); +}); + +test.each(["config root", "ancestor"])("startup CA waits for an ACL flight on its %s", async scope => { + const configDir = scope === "ancestor" ? join(root, "nested-home") : root; + mkdirSync(configDir, { recursive: true, mode: 0o700 }); + const entered = deferred(), release = deferred(); + setAsyncIcaclsRunnerForTests(async () => { entered.resolve(); await release.promise; return success; }); + const flight = hardenSecretDirAsync(root, { required: false }); + await entered.promise; + const startup = localCa.ensureLocalInterceptCaForStartup(configDir); + try { + await macrotask(); + expect(existsSync(join(configDir, "claude-intercept"))).toBe(false); + } finally { release.resolve(); await flight; await startup; } + expect(existsSync(join(configDir, "claude-intercept", "ca.pem"))).toBe(true); +}); + +test("a settled optional deadline still waits for its live ancestor child", async () => { + const configDir = join(root, "nested-home"); + mkdirSync(configDir, { mode: 0o700 }); + const entered = deferred(), release = deferred(); + let expire!: () => void; + let now = 0; + setNowForTests(() => now); + setAsyncIcaclsBeltSchedulerForTests(callback => { expire = callback; return () => {}; }); + setAsyncIcaclsRunnerForTests(async () => { entered.resolve(); await release.promise; return success; }); + const flight = hardenSecretDirAsync(root, { required: false, deadlineMs: 1_000 }); + await entered.promise; + now = 1_001; + expire(); + expect((await flight).ok).toBe(false); + let mutated = false; + const work = withConfigDirHardeningSettled(configDir, () => { mutated = true; }); + try { await macrotask(); expect(mutated).toBe(false); } + finally { release.resolve(); await work; } + expect(mutated).toBe(true); +}); + +test("the settled check and secret mutation cannot be split by a queued hardener", async () => { + const events: string[] = []; + let flight: Promise | undefined; + setAsyncIcaclsRunnerForTests(async () => { events.push("icacls"); return success; }); + await withConfigDirHardeningSettled(root, () => { + events.push("mutation starts"); + flight = hardenSecretDirAsync(root, { required: true }); + events.push("mutation ends"); + }); + await flight; + expect(events.slice(0, 3)).toEqual(["mutation starts", "mutation ends", "icacls"]); +}); + +test.each(["live-owner inspection", "post-untrust acknowledgement"])("pending cleanup gates %s with a fresh parent barrier", async step => { + mkdirSync(pickerStateDir(root)); + const ca = localCa.createCertificateAuthority({ commonName: "pending cleanup fixture" }); + writeFileSync(pickerCaPendingUntrustPath(root), JSON.stringify({ certPem: ca.certPem, ...pickerCaFingerprints(ca.certPem) })); + const entered = deferred(), release = deferred(), atStep = deferred(); + let held = false; + const overlapping: string[][] = []; + setAsyncIcaclsRunnerForTests(async () => { entered.resolve(); await release.promise; return success; }); + setIcaclsRunnerForTests(args => { + if (held && args[0]?.startsWith(pickerStateDir(root))) { overlapping.push(args); atStep.resolve(); } + return success; + }); + const barrier = configPaths.withConfigDirHardeningSettled; + entrySpy = spyOn(configPaths, "withConfigDirHardeningSettled").mockImplementation((dir: string, mutate: () => T) => { + const work = barrier(dir, mutate); + if (held) atStep.resolve(); + return work; + }); + const untrust = spyOn(pickerTrust, "untrustPickerCa").mockImplementation(async () => { + if (step === "live-owner inspection") return { ok: false }; + // Simulate a new optional parent flight during the OS await, invalidating successful + // child memos so the acknowledgement must really harden its SQLite lease again. + resetHardenedStateForTests(); + held = true; hardenConfigDir(); await entered.promise; + return { ok: true }; + }); + let work: Promise | undefined; + try { + if (step === "live-owner inspection") { held = true; hardenConfigDir(); await entered.promise; } + work = step === "live-owner inspection" + ? preparePersistentPickerAuthority({ configDir: root, platform: "win32" }).then(() => "unexpected success", error => error.message) + : drainPendingPickerCaUntrust(root); + await atStep.promise; + expect(overlapping).toEqual([]); + expect(existsSync(pickerCaPendingUntrustPath(root))).toBe(true); + if (step === "live-owner inspection") { + expect(existsSync(join(pickerStateDir(root), "ca.lock.sqlite"))).toBe(false); + expect(untrust).not.toHaveBeenCalled(); + } + } finally { held = false; release.resolve(); await work; untrust.mockRestore(); } + expect(await work).toBe(step === "live-owner inspection" ? "picker_ca_pending_untrust" : true); + expect(existsSync(pickerCaPendingUntrustPath(root))).toBe(step === "live-owner inspection"); +}); + +function aclClock() { + let now = 0; + const timers = new Map<() => void, number>(); + setNowForTests(() => now); + setAsyncIcaclsBeltSchedulerForTests((callback, milliseconds) => { + timers.set(callback, now + milliseconds); + return () => { timers.delete(callback); }; + }); + return { + advance(milliseconds: number) { + now += milliseconds; + for (const [callback, deadline] of timers) { + if (deadline <= now) { timers.delete(callback); callback(); } + } + }, + pending: () => timers.size, + }; +} +const nextTurn = () => new Promise(done => setImmediate(done)); + +test("picker profile publication drains a parent flight started during trust inspection", async () => { + const entered = deferred(), release = deferred(), atProfile = deferred(); + let held = false; + const overlapping: string[] = []; + setAsyncIcaclsRunnerForTests(async () => { entered.resolve(); await release.promise; return success; }); + setIcaclsRunnerForTests(args => { + if (held && args[0]?.startsWith(pickerStateDir(root))) overlapping.push(args[1]!); + return success; + }); + const barrier = configPaths.withConfigDirHardeningSettled; + entrySpy = spyOn(configPaths, "withConfigDirHardeningSettled").mockImplementation((dir: string, mutate: () => T) => { + const work = barrier(dir, mutate); if (held) atProfile.resolve(); return work; + }); + const inspect = spyOn(pickerTrust, "inspectPickerTrust").mockImplementation(async () => { + held = true; hardenConfigDir(); await entered.promise; return "trusted"; + }); + const runtime = { status: () => ({ desired: true, supported: true, trust: "trusted", listenerReady: true, + effective: true, latched: false, reason: "active", models: 0, snapshotAt: null, lastBootstrapAt: null }), + rearm: async () => {}, disarm: () => {}, refreshTrust: async () => "trusted" } as PickerRuntime; + const controller = createDesktopPickerController({ configDir: root, platform: "win32", runtime, + readConfig: () => ({ port: 10100, claudeCode: { desktopMode: "first-party" } }) as OcxConfig, + persistPreference: () => true, proxyPort: () => 10201, + }); + const work = controller.enable({ persist: false, context: "server" }); + // The unchanged writer may finish before the barrier exists; either path signals arrival. + void work.then(() => atProfile.resolve()); + try { + await atProfile.promise; + expect(overlapping).toEqual([]); + expect(existsSync(join(pickerStateDir(root), "profile-state.json"))).toBe(false); + } finally { held = false; release.resolve(); await work; inspect.mockRestore(); } + expect(await work).toMatchObject({ reason: "restart_required", profile: "applied", effective: true }); + expect(existsSync(join(pickerStateDir(root), "profile-state.json"))).toBe(true); +}); + +test("picker snapshot persistence drains a parent flight started during discovery", async () => { + const entered = deferred(), release = deferred(), atPersist = deferred(); + const file = join(pickerStateDir(root), "models.json"); + setAsyncIcaclsRunnerForTests(async () => { entered.resolve(); await release.promise; return success; }); + const barrier = configPaths.withConfigDirHardeningSettled; + entrySpy = spyOn(configPaths, "withConfigDirHardeningSettled").mockImplementation((dir: string, mutate: () => T) => { + const work = barrier(dir, mutate); atPersist.resolve(); return work; + }); + const snapshot = createPickerModelSnapshot(async () => { + hardenConfigDir(); await entered.promise; return { nativeSlugs: [], routedModels: [] }; + }, file); + const work = snapshot.refresh(); + void work.then(() => atPersist.resolve()); + try { + await atPersist.promise; + expect(existsSync(file)).toBe(false); + expect(snapshot.current()).toBeNull(); + } finally { release.resolve(); await work; } + expect(existsSync(file)).toBe(true); + expect(snapshot.current()?.models).toEqual([]); +}); + +test.each(["mutation", "active config flight", "startup", "offline apply", "native PUT", "agent-settings POST", "CLI-intent PUT", "picker transition"])("a retained child deadline refuses %s without publishing or losing cleanup ownership", async caller => { + const clock = aclClock(); + const entered = deferred(), release = deferred(), atAdmission = deferred(); + const config = { port: 10100, providers: {}, defaultProvider: "openai", + claudeCode: { desktopMode: "first-party", intercept: { picker: false } } } as OcxConfig; + writeFileSync(join(root, "config.json"), JSON.stringify(config)); + setAsyncIcaclsRunnerForTests(async () => { entered.resolve(); await release.promise; return success; }); + const barrier = configPaths.withConfigDirHardeningSettled; + entrySpy = spyOn(configPaths, "withConfigDirHardeningSettled").mockImplementation((dir: string, mutate: () => T) => { + const work = barrier(dir, mutate); atAdmission.resolve(); return work; + }); + hardenConfigDir(); await entered.promise; + if (caller !== "active config flight") { + clock.advance(100_000); + await configPaths.flushConfigDirHardening(root); + expect(windowsSecretAclReapPendingForPath(root)).toBe(true); + } + let mutated = false; + let outcome: unknown = "pending"; + const controller = createDesktopPickerController({ configDir: root, platform: "win32", + readConfig: () => ({ ...config, claudeCode: { desktopMode: "first-party" } }) as OcxConfig, + persistPreference: () => true, proxyPort: () => 10201, + runtime: { status: () => ({ desired: true, supported: true, trust: "unknown", listenerReady: false, + effective: false, latched: false, reason: "starting", models: 0, snapshotAt: null, lastBootstrapAt: null }) } as PickerRuntime, + }); + const work = (caller === "mutation" || caller === "active config flight" + ? configPaths.withConfigDirHardeningSettled(root, () => { mutated = true; }) + : caller === "startup" + ? localCa.ensureLocalInterceptCaForStartup(root) + : caller === "picker transition" + ? controller.enable({ persist: false, context: "server" }) + : caller === "offline apply" + ? applyDesktop(undefined, { kind: "first-party" }, { findLiveProxyImpl: async () => null }) + : (async () => { + const path = caller === "native PUT" ? "/api/native-integrations/claude-desktop" + : caller === "CLI-intent PUT" ? "/api/claude-code" : "/api/claude-desktop/apply"; + const url = new URL(`http://127.0.0.1:10100${path}`); + const response = await handleManagementAPI(new Request(url, { method: caller === "agent-settings POST" ? "POST" : "PUT", + headers: { Host: url.host, "Content-Type": "application/json" }, + body: JSON.stringify(caller === "native PUT" ? { enabled: true } : caller === "CLI-intent PUT" ? { cliFirstParty: true } : { mode: "first-party" }), + }), url, config, { ensureClaudeIntercept: async () => ({ ok: true, + state: { proxyPort: 10200, caCertPath: join(root, "claude-intercept", "ca.pem"), pickerProxyPort: null } }) }, + "admin-token", undefined, { trustedLoopback: true }); + expect(response!.status).toBe(500); + return response!.json(); + })()) + .then(value => { outcome = value; }, error => { outcome = { code: error.code, message: error.message }; }); + try { + await atAdmission.promise; await nextTurn(); + expect(outcome).toBe("pending"); + clock.advance(999); await nextTurn(); + expect(outcome).toBe("pending"); + clock.advance(1); await nextTurn(); + expect(outcome).not.toBe("pending"); + if (["mutation", "active config flight", "startup"].includes(caller)) { + expect(outcome).toMatchObject({ code: "ETIMEDOUT", message: "ACL settlement timed out" }); + } else if (caller === "picker transition") { + expect(outcome).toMatchObject({ reason: "profile_failed", effective: false }); + expect(controller.busy()).toBe(false); + expect(await controller.transition(async () => "lock released")).toBe("lock released"); + } else { + expect(outcome).toMatchObject({ diagnostic: { stage: "ca", cause: "ETIMEDOUT" } }); + if (caller === "offline apply") expect(outcome).toMatchObject({ ok: false, reason: "ca_unavailable" }); + } + expect(mutated).toBe(false); + expect(existsSync(join(root, "claude-intercept"))).toBe(false); + if (caller !== "active config flight") { + expect(windowsSecretAclReapPendingForPath(root)).toBe(true); + expect(clock.pending()).toBe(0); // No startup lease retry, timer leak, or second deadline. + } + } finally { release.resolve(); await work; } + expect(mutated).toBe(false); + expect(existsSync(join(root, "claude-intercept"))).toBe(false); +}); + +test("new overlapping work cannot renew the admission deadline", async () => { + const clock = aclClock(); + const firstEntered = deferred(), firstRelease = deferred(), secondEntered = deferred(), secondRelease = deferred(); + const later = join(root, "later"); mkdirSync(later); + setAsyncIcaclsRunnerForTests(async args => { + if (args[0] === root) { firstEntered.resolve(); await firstRelease.promise; } + else { secondEntered.resolve(); await secondRelease.promise; } + return success; + }); + const first = hardenSecretDirAsync(root, { required: false }); await firstEntered.promise; + let mutated = false, outcome: unknown = "pending"; + const admission = withConfigDirHardeningSettled(root, () => { mutated = true; }).catch(error => { outcome = error.code; }); + await nextTurn(); clock.advance(600); + const second = hardenSecretDirAsync(later, { required: false }); await secondEntered.promise; + firstRelease.resolve(); await first; await nextTurn(); + try { + expect(outcome).toBe("pending"); + clock.advance(400); await nextTurn(); + expect(outcome).toBe("ETIMEDOUT"); + expect(mutated).toBe(false); + } finally { secondRelease.resolve(); await second; await admission; } + expect(mutated).toBe(false); + expect(clock.pending()).toBe(0); +}); + +test("diagnostics discard arbitrary error codes, names and messages", () => { + const sensitive = "private-fixture-value"; + const ca = spyOn(localCa, "ensureLocalInterceptCa").mockImplementation(() => { + throw Object.assign(new Error(sensitive), { code: sensitive, name: sensitive }); + }); + const warnings = spyOn(console, "warn").mockImplementation(() => {}); + try { + const result = firstParty.applyDesktopFirstParty({ port: 10100 }); + expect(result).toMatchObject({ ok: false, reason: "ca_unavailable", diagnostic: { stage: "ca", cause: "unknown" } }); + expect(JSON.stringify(result)).not.toContain(sensitive); + expect(warnings).toHaveBeenCalledWith("[claude-intercept] secret preparation failed: stage=ca cause=unknown"); + } finally { ca.mockRestore(); warnings.mockRestore(); } +}); + +test.each(["native PUT", "agent-settings POST", "CLI-intent PUT", "offline CLI"])("%s waits for the config ACL flight before creating intercept secrets", async entry => { + const config = { port: 10100, providers: {}, defaultProvider: "openai", + claudeCode: { desktopMode: "first-party", intercept: { picker: false } } } as OcxConfig; + writeFileSync(join(root, "config.json"), JSON.stringify(config)); + const entered = deferred(), release = deferred(), atApply = deferred(); + let pending = true; + const overlapping: string[] = []; + setAsyncIcaclsRunnerForTests(async args => { + if (args[0] === root) { entered.resolve(); await release.promise; } + return success; + }); + setIcaclsRunnerForTests(args => { + if (pending && args[0]?.startsWith(join(root, "claude-intercept"))) { + overlapping.push(args[1]!); atApply.resolve(); + } + return success; + }); + const originalBarrier = configPaths.withConfigDirHardeningSettled; + entrySpy = spyOn(configPaths, "withConfigDirHardeningSettled").mockImplementation((dir: string, mutate: () => T): Promise => { + const work = originalBarrier(dir, mutate); + atApply.resolve(); return work; + }); + hardenConfigDir(); + await entered.promise; + const request = entry === "offline CLI" + ? applyDesktop(undefined, { kind: "first-party" }, { findLiveProxyImpl: async () => null }) + : (async () => { + const path = entry === "native PUT" ? "/api/native-integrations/claude-desktop" + : entry === "CLI-intent PUT" ? "/api/claude-code" : "/api/claude-desktop/apply"; + const url = new URL(`http://127.0.0.1:10100${path}`); + const response = await handleManagementAPI(new Request(url, { method: entry === "agent-settings POST" ? "POST" : "PUT", + headers: { Host: url.host, "Content-Type": "application/json" }, + body: JSON.stringify(entry === "native PUT" ? { enabled: true } : entry === "CLI-intent PUT" ? { cliFirstParty: true } : { mode: "first-party" }), + }), url, config, { ensureClaudeIntercept: async () => ({ ok: true, + state: { proxyPort: 10200, caCertPath: join(root, "claude-intercept", "ca.pem"), pickerProxyPort: null } }) }, + "admin-token", undefined, { trustedLoopback: true }); + const body = await response!.json(); + expect(response!.status, JSON.stringify(body)).toBe(200); + return body; + })(); + try { + // Observe arrival at either the real barrier or the first child ACL command. This + // handshake covers dynamic imports without sleeps or a guessed microtask count. + await atApply.promise; + expect(overlapping).toEqual([]); + expect(existsSync(join(root, "claude-intercept"))).toBe(false); + } finally { pending = false; release.resolve(); await request; } + expect(await request).toMatchObject({ ok: true }); + expect(existsSync(join(root, "claude-intercept", "ca.pem"))).toBe(true); + expect(readClaudeInterceptProxyToken(root)).not.toBeNull(); +}); + +test("desired-state CLI reconciliation waits immediately before its synchronous leaf", async () => { + const config = { port: 10100, claudeCode: { desktopMode: "first-party", cliFirstParty: true } } as OcxConfig; + const entered = deferred(), release = deferred(), inspected = deferred(); + setAsyncIcaclsRunnerForTests(async () => { entered.resolve(); await release.promise; return success; }); + hardenConfigDir(); + await entered.promise; + const original = firstParty.inspectDesktopFirstParty; + const inspection = spyOn(firstParty, "inspectDesktopFirstParty").mockImplementation((...args) => { + const result = original(...args); inspected.resolve(); return result; + }); + const work = ensureClaudeDesktopMatchesDesired({ loadConfig: () => config, log: () => {}, error: () => {} } as Parameters[0]); + try { + await inspected.promise; + expect(existsSync(join(root, "claude-intercept"))).toBe(false); + } finally { release.resolve(); await work; inspection.mockRestore(); } + expect(readClaudeInterceptProxyToken(root)).not.toBeNull(); +}); + +test("runtime token creation drains a parent flight started during the CA await", async () => { + const ca = localCa.ensureLocalInterceptCa(root); + const entered = deferred(), release = deferred(), returnedCa = deferred(); + setAsyncIcaclsRunnerForTests(async () => { entered.resolve(); await release.promise; return success; }); + const startupCa = spyOn(localCa, "ensureLocalInterceptCaForStartup").mockImplementation(async () => { + hardenConfigDir(); await entered.promise; returnedCa.resolve(); return ca; + }); + const work = startClaudeIntercept({ config: { claudeCode: { cliFirstParty: true } } as OcxConfig, + publicPort: 10100, configDir: root, dispatch: async () => new Response("ok"), pickerPlatform: "linux", + startProxy: async (_port, options) => startConnectProxy(0, options), + }); + try { + await returnedCa.promise; + await macrotask(); + expect(readClaudeInterceptProxyToken(root)).toBeNull(); + } finally { + release.resolve(); + try { await (await work)?.stop(); } finally { startupCa.mockRestore(); } + } + expect(readClaudeInterceptProxyToken(root)).not.toBeNull(); +}); + +// #7777 review r2 F3: a CLAUDE_CONFIG_DIR nested in the opencodex home shares the config-root +// flight, so its credential-bearing settings rewrites need the same fresh admission. +test("startup settings migration after the proxy bind waits for a parent flight over a nested Claude home", async () => { + const overlapping: string[] = []; + let held = false; + setIcaclsRunnerForTests(args => { + if (held && args[0]?.startsWith(join(root, "claude") + sep)) overlapping.push(args[1] ?? ""); + return success; + }); + const entered = deferred(), release = deferred(); + setAsyncIcaclsRunnerForTests(async () => { entered.resolve(); await release.promise; return success; }); + expect(firstParty.applyDesktopFirstParty({ port: 10100 })).toMatchObject({ ok: true }); + const work = startClaudeIntercept({ config: { port: 10100, claudeCode: { cliFirstParty: true } } as OcxConfig, + configDir: root, publicPort: 10100, dispatch: async () => new Response("ok"), pickerPlatform: "linux", + startProxy: async (_port, options) => { + const proxy = await startConnectProxy(0, options); + held = true; hardenConfigDir(); await entered.promise; + return proxy; + }, + }); + let handle: Awaited | undefined; + try { + await entered.promise; + await macrotask(); + expect(overlapping).toEqual([]); + } finally { + held = false; release.resolve(); + handle = await work; + } + try { + // The ordering check alone would also pass if startup skipped the migration entirely. + const env = (JSON.parse(readFileSync(join(root, "claude", "settings.json"), "utf8")) as { env?: Record }).env; + expect(handle?.proxyPort).toBeGreaterThan(0); + expect(env?.HTTPS_PROXY).toContain(`:${handle?.proxyPort}`); + } finally { await handle?.stop(); } +}); + +test("settled first-party removal waits for a parent flight over a nested Claude home", async () => { + expect(firstParty.applyDesktopFirstParty({ port: 10100 })).toMatchObject({ ok: true }); + const entered = deferred(), release = deferred(); + setAsyncIcaclsRunnerForTests(async () => { entered.resolve(); await release.promise; return success; }); + const flight = hardenSecretDirAsync(root, { required: false }); + await entered.promise; + let removed = false; + const work = firstParty.removeDesktopFirstPartySettled(() => ({}), {}, (config, options) => { + removed = true; + return firstParty.removeDesktopFirstParty(config, options); + }); + try { await macrotask(); expect(removed).toBe(false); } + finally { release.resolve(); await flight; } + expect(await work).toMatchObject({ ok: true, changed: true }); + expect(removed).toBe(true); +}); + +// #7777 review r3 F4: the model-defaults rewrite also runs after a body await on the Claude home. +test("model-defaults POST waits for a parent flight over a nested Claude home", async () => { + const home = join(root, "claude"); + mkdirSync(home, { recursive: true }); + const path = join(home, "settings.json"); + writeFileSync(path, JSON.stringify({ env: { HTTPS_PROXY: "http://opencodex:fixture-token@127.0.0.1:10200" } })); + const models = { sonnet: "claude-sonnet-4-6" }; + const fingerprint = previewClaudeModelDefaults(models).fingerprint; + const overlapping: string[] = []; + let held = true; + setIcaclsRunnerForTests(args => { if (held && args[0]?.startsWith(home + sep)) overlapping.push(args[1] ?? ""); return success; }); + const entered = deferred(), release = deferred(); + setAsyncIcaclsRunnerForTests(async () => { entered.resolve(); await release.promise; return success; }); + hardenConfigDir(); + await entered.promise; + const url = new URL("http://127.0.0.1:10100/api/claude-code/model-defaults"); + const work = handleManagementAPI(new Request(url, { method: "POST", headers: { Host: url.host, "Content-Type": "application/json" }, + body: JSON.stringify({ fingerprint }) }), url, { port: 10100, providers: {}, claudeCode: { tierModels: models } } as OcxConfig, + {}, "admin-token", undefined, { trustedLoopback: true }); + try { + await macrotask(); + expect(overlapping).toEqual([]); + expect(JSON.parse(readFileSync(path, "utf8")).env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBeUndefined(); + } finally { held = false; release.resolve(); } + expect((await work)?.status).toBe(200); + expect(JSON.parse(readFileSync(path, "utf8")).env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe(models.sonnet); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 7e5022b3319..6c2fea430f7 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,5 @@ { "provider-input-modalities.test.ts": "providers", "workbuddy-quota.test.ts": "providers", "workbuddy-login.test.ts": "oauth", "workbuddy-adapter.test.ts": "adapters", "workbuddy-provider.test.ts": "providers", "link-tunnel-history.test.ts": "clients", "link-readiness.test.ts": "clients", "link-diagnostics.test.ts": "clients", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "responses-phase-inference.test.ts": "responses", + "claude-intercept-acl-barrier.test.ts": "claude-integration", "codex-catalog-heal-reanchor.test.ts": "codex-integration", "tool-identity-hash.test.ts": "adapters", "server-combo-required-tool-contract.test.ts": "server", From 19b7f19b70373e73b05ded6b1c1f00a5c1c4f440 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Tue, 27 Oct 2026 01:23:30 +0000 Subject: [PATCH 099/101] fix(config): skip optional hardening for non-directory homes (#7688) * fix(codex): preserve hardlink names in catalog heal paths * fix(codex): retain native catalog leaf aliases where safe * fix(codex): fence uncertain catalog entry identity * fix(codex): retain uncertainty after catalog hop exhaustion * fix(catalog): retain uncertainty after symlink lookup failures * fix(test): promote Bun 1.4.4 with independent runner role verification * docs: align Docker Bun comments with unified role defaults * test: use a draining TLS origin for early upload replies * test: confine draining origin to buffered HTTP/1 upload * fix(codex): resolve catalog link target parents with filesystem semantics A relative symlink target containing .. was normalized lexically, so a symlinked intermediate directory made the comparator follow the wrong parent. Walk each intermediate component within the existing read budget: resolve symlinked or missing directories with realpath, treat non-directories and failures as unknown, and keep the leaf for the next lstat hop. * fix(codex): match platform .. semantics in catalog link targets Measured OS behavior splits by platform: Windows normalizes .. lexically across junctions/reparse points and even past missing components, while POSIX resolves each directory and refuses a missing crossing. Keep lexical resolve on Windows; on POSIX walk each intermediate component inside the read budget, record intermediate symlinks in the revalidation snapshot, and treat missing or non-directory crossings as unknown. Regressions assert the independent readFileSync/realpathSync result before the comparator verdict, and the POSIX-only literal-backslash case is an explicit platform skip. * fix(codex): refuse non-directory crossings in POSIX link targets A symlinked intermediate that resolves to a regular file must fail the traversal with ENOTDIR on POSIX; dirname() after realpath would have accepted the wrong catalog. Stat the resolved intermediate inside the read budget, require a directory, and record the resolved identity in the link snapshot so revalidation also catches a target swap. * fix(test): drain account fixture config ACL work before removal * fix(config): skip optional directory hardening for non-directories * test(codex): reproduce catalog observation and reanchor regressions * fix(codex): revalidate catalog evidence and reanchor selected publications * fix(codex): retain recovery baseline when publication proof fails --------- Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> --- src/config/paths.ts | 5 +-- structure/config.md | 2 +- .../codex-account-slot-identity.test.ts | 22 ++++++++---- .../codex-auth-headless-accounts.test.ts | 21 ++++++++---- .../windows-config-read-acl-memo.test.ts | 34 ++++++++++++++++++- 5 files changed, 67 insertions(+), 17 deletions(-) diff --git a/src/config/paths.ts b/src/config/paths.ts index 9f470b9ebd4..cab40800655 100644 --- a/src/config/paths.ts +++ b/src/config/paths.ts @@ -1,4 +1,4 @@ -import { chmodSync, existsSync } from "node:fs"; +import { chmodSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { join, resolve } from "node:path"; import { flushWindowsSecretAclReapsBeforeRemoval, hardenSecretDirAsync, windowsSecretAclApplies, withWindowsSecretAclSettled } from "../lib/windows-secret-acl"; @@ -37,7 +37,8 @@ export function hardenConfigDir(): void { // The guard runs before any mutation: refusing after chmod/ACL would already // have changed the protected directory used by the test-home boundary. assertNotRealHomeUnderTest(dir); - if (!existsSync(dir)) return; + // Follow directory links as before, but never apply directory permissions to a file. + try { if (!statSync(dir).isDirectory()) return; } catch { return; } // Windows privacy is enforced by NTFS ACLs. chmod only changes DOS attributes there; // doing it before the ACL lookup can invalidate its ctime-based successful observation. if (!windowsSecretAclApplies()) { diff --git a/structure/config.md b/structure/config.md index 079955e02c9..604f1c839b7 100644 --- a/structure/config.md +++ b/structure/config.md @@ -60,7 +60,7 @@ returns unavailable for missing, invalid, or persistently conflicting config. It test seam and the mutation types remain re-exported through `src/config.ts`. Successful, repaired, and salvaged file loads record their source path in the private WeakMap owned by `src/config/rebase-provenance.ts`; defaults and synthetic objects do not acquire it. This metadata is never serialized. Inventory drift cannot turn a file-backed instance into a synthetic discovery writer. Read publication may use the detached policy projection described by the [catalog contract](catalog.md#shared-catalog), without changing the live merge baseline; changing the resolved config home refuses publication. `src/config/paths.ts` owns `OPENCODEX_HOME` expansion and resolution, the config directory and `config.json` path. A relative home is resolved once per distinct raw environment value, so a later working-directory change cannot -silently move the active installation. Config reads in this leaf and `src/config/load-degrade.ts` use NTFS ACLs on Windows and private POSIX modes elsewhere. Windows reads omit `chmod` before ACL hardening so repeated loads preserve successful object/freshness observations; external metadata changes still invalidate them. Directory flights keep their existing flush/reap lifecycle, and file/required-write failure policy is unchanged. `tests/windows/windows-config-read-acl-memo.test.ts` covers repeated loads, external invalidation and POSIX permissions. +silently move the active installation. Config reads in this leaf and `src/config/load-degrade.ts` use NTFS ACLs on Windows and private POSIX modes elsewhere. Optional directory hardening follows directory symlinks/junctions but skips non-directory or unstatable targets before starting chmod or ACL work. Windows reads omit `chmod` before ACL hardening so repeated loads preserve successful object/freshness observations; external metadata changes still invalidate them. Directory flights keep their existing flush/reap lifecycle, and file/required-write failure policy is unchanged. `tests/windows/windows-config-read-acl-memo.test.ts` covers repeated loads, non-directory and directory-link behavior, external invalidation and POSIX permissions. Intercept writers use `withConfigDirHardeningSettled` from `src/config/paths.ts` after their last unrelated await. The registry includes the optional config flight, ancestors and retained timeout children; one existing ACL harden deadline bounds admission, including newly overlapping work. Expiry throws sanitized `WindowsSecretAclSettleTimeoutError` (`ETIMEDOUT`) without running the writer or relinquishing child cleanup ownership. The final empty-registry check and synchronous callback share one turn; the callback contains no await. Picker cleanup independently gates both SQLite owner inspection and post-untrust acknowledgement; profile and model-snapshot publication re-enter after trust/discovery awaits. Lexical/canonical aliases and uncertain identities drain conservatively. Removal retains its separate at-or-below scope and actual-exit requirement. diff --git a/tests/codex-integration/codex-account-slot-identity.test.ts b/tests/codex-integration/codex-account-slot-identity.test.ts index e62a1f0f807..1177ef62b77 100644 --- a/tests/codex-integration/codex-account-slot-identity.test.ts +++ b/tests/codex-integration/codex-account-slot-identity.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { flushConfigDirHardening } from "../../src/config/paths"; import { saveCodexAccountCredential } from "../../src/codex/account-store"; import { codexAccountUnusableReason } from "../../src/codex/account-usability"; import { reconcileMainCodexAccountRuntimeState, resetMainCodexAccountIdentityTrackingForTests } from "../../src/codex/account-lifecycle"; @@ -13,7 +14,7 @@ import { clearAccountQuota } from "../../src/codex/quota"; import { clearCodexUpstreamHealth, clearThreadAccountMap } from "../../src/codex/routing"; import { runPassiveRead } from "../../src/lib/passive-read"; import type { OcxConfig } from "../../src/types"; -import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { drainAndRemoveFixtureRoots } from "../helpers/fixture-teardown"; /** * Security proof for the slot identity fields on the account-list API: `slotId`, `slotKind` @@ -94,14 +95,21 @@ beforeEach(() => { resetRuntime(); }); -afterEach(() => { +afterEach(async () => { globalThis.fetch = originalFetch; resetRuntime(); - for (const [name, value] of Object.entries(saved)) { - if (value === undefined) delete process.env[name]; - else process.env[name] = value; - } - removeTreeWithRetry(home); + await drainAndRemoveFixtureRoots({ + roots: [{ path: home }], + restoreEnvironment: () => { + for (const [name, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + }, + }, { + // The tracked flight belongs to the nested config directory, not its fixture root. + settleConfigFlights: () => flushConfigDirHardening(join(home, "ocx")), + }); }); describe("Codex account slot identity metadata", () => { diff --git a/tests/codex-integration/codex-auth-headless-accounts.test.ts b/tests/codex-integration/codex-auth-headless-accounts.test.ts index d5d1c815298..d095cebfcd5 100644 --- a/tests/codex-integration/codex-auth-headless-accounts.test.ts +++ b/tests/codex-integration/codex-auth-headless-accounts.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { flushConfigDirHardening } from "../../src/config/paths"; import { getCodexAccountCredential, saveCodexAccountCredential } from "../../src/codex/account-store"; import { resetMainCodexAccountIdentityTrackingForTests } from "../../src/codex/account-lifecycle"; import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "../../src/codex/account-runtime-state"; @@ -19,6 +20,7 @@ import { clearAccountQuota } from "../../src/codex/quota"; import { resetQuotaQueryBackoffForTests } from "../../src/codex/quota-query-backoff"; import { resetLifecycleDrainStateForTests } from "../../src/server/lifecycle"; import type { OcxConfig } from "../../src/types"; +import { drainAndRemoveFixtureRoots } from "../helpers/fixture-teardown"; import { removeTreeWithRetry } from "../helpers/remove-tree"; let root: string; @@ -96,16 +98,23 @@ beforeEach(() => { resetRuntimeState(); }); -afterEach(() => { +afterEach(async () => { defaultHomeSpy?.mockRestore(); defaultHomeSpy = undefined; globalThis.fetch = originalFetch; resetRuntimeState(); - if (previousOcxHome === undefined) delete process.env.OPENCODEX_HOME; - else process.env.OPENCODEX_HOME = previousOcxHome; - if (previousCodexHome === undefined) delete process.env.CODEX_HOME; - else process.env.CODEX_HOME = previousCodexHome; - removeTreeWithRetry(root); + await drainAndRemoveFixtureRoots({ + roots: [{ path: root }], + restoreEnvironment: () => { + if (previousOcxHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOcxHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + }, + }, { + // Tests can replace OPENCODEX_HOME; await the original fixture's exact flight key. + settleConfigFlights: () => flushConfigDirHardening(join(root, "ocx")), + }); }); describe("account listing without a native Codex home", () => { diff --git a/tests/windows/windows-config-read-acl-memo.test.ts b/tests/windows/windows-config-read-acl-memo.test.ts index ec7ed5160a2..2fc46e74943 100644 --- a/tests/windows/windows-config-read-acl-memo.test.ts +++ b/tests/windows/windows-config-read-acl-memo.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; -import { chmodSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, statSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { loadConfig } from "../../src/config"; @@ -71,6 +71,38 @@ test("repeated Windows config reads preserve successful ACL observations", async expect(readFileSync(join(home, "config.json"), "utf8")).toBe(before); }); +for (const platform of ["win32", "darwin"]) { + test(`optional directory hardening leaves a regular file unchanged on ${platform}`, async () => { + Object.defineProperty(process, "platform", { value: platform, configurable: true }); + acl.setPlatformForTests(platform); + const path = join(home, "not-a-config-directory"); + writeFileSync(path, "fixture-unsafe-root"); + const mode = statSync(path).mode; + process.env.OPENCODEX_HOME = path; + + hardenConfigDir(); + await flushConfigDirHardeningForTests(); + + expect(calls).toEqual([]); + expect(readFileSync(path, "utf8")).toBe("fixture-unsafe-root"); + expect(statSync(path).mode).toBe(mode); + }); +} + +test("optional directory hardening still follows a directory symlink or junction", async () => { + const target = join(home, "directory-target"); + const link = join(home, "directory-link"); + mkdirSync(target); + symlinkSync(target, link, originalPlatform === "win32" ? "junction" : "dir"); + process.env.OPENCODEX_HOME = link; + + hardenConfigDir(); + await flushConfigDirHardeningForTests(); + + expect(calls.length).toBeGreaterThan(0); + expect(statSync(link).isDirectory()).toBe(true); +}); + test("an external metadata change still invalidates a Windows file ACL observation", () => { const path = join(home, "auth.json"); let freshness = 1n; From 1de2b71b02d6b208645c7606dace0f8857eceefd Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 27 Oct 2026 10:41:24 +0900 Subject: [PATCH 100/101] ci: run import-graph tests on pull requests and verify merged batches on dev (#7782) --- .github/workflows/ci.yml | 55 +++++++-- AGENTS.md | 7 +- MAINTAINERS.md | 10 ++ scripts/ci/run-changed-tests.sh | 50 ++++++++ scripts/ci/select-test-lane.sh | 82 +++++++++++++ tests/ci-workflows/ci-review-lanes.test.ts | 18 ++- tests/ci-workflows/ci-scope-gaps.test.ts | 2 +- tests/ci-workflows/ci-scope-reduction.test.ts | 30 +++++ tests/ci-workflows/ci-test-lane.test.ts | 116 ++++++++++++++++++ 9 files changed, 357 insertions(+), 13 deletions(-) create mode 100755 scripts/ci/run-changed-tests.sh create mode 100755 scripts/ci/select-test-lane.sh create mode 100644 tests/ci-workflows/ci-test-lane.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f302557ba68..b6d7e7ad467 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,6 @@ # ============================================================================ # CI POLICY (maintainer decision — do not change in a contributor PR) -# * Pull requests run ONLY the fast Ubuntu subset. +# * Pull requests run ONLY the fast Ubuntu subset (default: gates + `test changed`). # * Full cross-platform (macOS/Windows) CI runs ONLY via manual # workflow_dispatch and on push to main/preview (release lines). # * NEVER add a dev push trigger or pull_request runs for macOS/Windows jobs. @@ -25,11 +25,9 @@ on: # carry contributor prefixes (`fix/`, `feat/`, `agent/`) as readily as # `codex/`, and contributor stacks need CI most. The `changes` job below is # the real scope gate, using the same allowlist as the push trigger. Safe to - # widen here - # because this workflow is `pull_request` (not `pull_request_target`), + # widen here because this workflow is `pull_request` (not `pull_request_target`), # declares `contents: read`, and reads no secrets. # - # # Pull requests run only the fast Ubuntu subset. Every macOS and Windows # job (and the macOS/Windows legs of the keyring, npm-global, setup-action # and remote-helper matrices) is skipped on `pull_request` and runs after @@ -94,7 +92,6 @@ on: permissions: contents: read -# Retrigger CI after dir-fsync / oauth deadline follow-ups (tip 34a1ac46). concurrency: # `push` and `pull_request` want supersession: a newer head on the same ref # makes the older one irrelevant, and cancelling it saves an hour of runners @@ -216,6 +213,7 @@ jobs: # of silently making every expensive job skip. ci: ${{ steps.scope.outputs.ci }} desktop: ${{ steps.scope.outputs.desktop }} + fast: ${{ steps.lane.outputs.fast }} # true: `test changed` replaces the four shards native: ${{ steps.matrices.outputs.native }} # Matrix include lists for keyring-smoke and npm-global-smoke, built and # shape-checked by the same validation step as `native`. @@ -590,6 +588,15 @@ jobs: esac done + - name: Choose the pull request test lane + id: lane + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + CI_SCOPE: ${{ steps.scope.outputs.ci }} + FULL_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'ci:full') }} + run: bash scripts/ci/select-test-lane.sh + - name: Decide whether the Windows shards run id: windows shell: bash @@ -647,7 +654,7 @@ jobs: test: name: test ${{ matrix.shard }}/4 needs: changes - if: github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true' + if: github.event_name != 'pull_request' || (needs.changes.outputs.ci == 'true' && needs.changes.outputs.fast != 'true') runs-on: ubuntu-latest # A quarter of the suite. A shard that needs longer than this is wedged, not # slow — the old 30-minute ceiling was margin for the Windows leg, which no @@ -710,6 +717,27 @@ jobs: TEST_SHARD: ${{ matrix.shard }}/4 run: bash scripts/ci/run-bun-test-batches.sh "$TEST_SHARD" + # Fast lane: import-graph tests for the diff; the batched dev dispatch is the backstop. + test-changed: + name: test changed + needs: changes + if: github.event_name == 'pull_request' && needs.changes.outputs.fast == 'true' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} + fetch-depth: 2 # merge commit + base tip, which the selection diffs against + - uses: ./.github/actions/setup-project-bun + with: + role: test-runner # See the Bun roles contract above test. + - name: Install dependencies and build the GUI the tests serve + run: bun install --frozen-lockfile && cd gui && bun install --frozen-lockfile && bun run build + - name: Test the files this pull request reaches + run: bash scripts/ci/run-changed-tests.sh + # Bun 1.3.14 has shown a Linux isolate/epoll race around the storage-policy # harness. Keep the entire six-file family in one fresh process so a runtime # failure is bounded to this job instead of poisoning a general test shard. @@ -1750,7 +1778,7 @@ jobs: # direct dependencies only, so a failing `select-windows-runner` would # otherwise reach this gate as nothing at all while its dependents report # `skipped`, which is the shape the step below is written to catch. - needs: [changes, select-windows-runner, test, storage-policy, api-usage, gates, platform-macos, macos-control, platform-windows, keyring-smoke, docker-smoke, docs-site-build, structure-gate, privacy-gate, npm-global-smoke, widget, desktop-shell, setup-action, remote-helper] + needs: [changes, select-windows-runner, test, test-changed, storage-policy, api-usage, gates, platform-macos, macos-control, platform-windows, keyring-smoke, docker-smoke, docs-site-build, structure-gate, privacy-gate, npm-global-smoke, widget, desktop-shell, setup-action, remote-helper] runs-on: ubuntu-latest timeout-minutes: 5 permissions: @@ -1766,6 +1794,7 @@ jobs: EVENT_NAME: ${{ github.event_name }} LANE: ${{ github.event.inputs.lane }} CHANGES_CI: ${{ needs.changes.outputs.ci }} + CHANGES_FAST: ${{ needs.changes.outputs.fast }} CHANGES_PACKAGING: ${{ needs.changes.outputs.packaging }} CHANGES_DOCS: ${{ needs.changes.outputs.docs }} CHANGES_STRUCTURE: ${{ needs.changes.outputs.structure }} @@ -1796,6 +1825,12 @@ jobs: if [ "$EVENT_NAME" = "pull_request" ] && [ "$CHANGES_CI" != "true" ]; then scoped=not-requested fi + full_tests=$scoped # the fast lane swaps the shards for test changed; unset = full + changed_tests=not-requested + if [ "$EVENT_NAME" = "pull_request" ] && [ "$scoped" = requested ] && [ "${CHANGES_FAST:-false}" = "true" ]; then + full_tests=not-requested + changed_tests=requested + fi # platform-macos and widget never run on a pull request (macOS runs after merge). # desktop-shell (ubuntu) carries the ordinary scope gate AND the native or # package-E2E path filters. This mirrors those expressions exactly; where it @@ -1866,7 +1901,7 @@ jobs: # One line per job, mirroring that job's own `if:`. Adding a job to this workflow # without adding it here fails the gate by name rather than passing unnoticed. - GATED_JOBS="changes select-windows-runner test storage-policy api-usage gates" + GATED_JOBS="changes select-windows-runner test test-changed storage-policy api-usage gates" GATED_JOBS="$GATED_JOBS platform-macos keyring-smoke docker-smoke npm-global-smoke" GATED_JOBS="$GATED_JOBS macos-control platform-windows docs-site-build" GATED_JOBS="$GATED_JOBS structure-gate widget" @@ -1877,7 +1912,9 @@ jobs: expected_for() { case "$1" in changes|select-windows-runner) echo requested ;; - test|storage-policy|api-usage|gates) + test) echo "$full_tests" ;; + test-changed) echo "$changed_tests" ;; + storage-policy|api-usage|gates) echo "$scoped" ;; docker-smoke) echo "$docker" ;; keyring-smoke) echo "$keyring" ;; diff --git a/AGENTS.md b/AGENTS.md index 2ba03009389..2e1c13e6dca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -367,7 +367,12 @@ than nudged. ## CI policy (maintainer decision — do not change) -- Pull requests run **only the fast Ubuntu subset**. +- Pull requests run **only the fast Ubuntu subset**. By default that is `gates` plus + `test changed` (import-graph tests for the diff); the four full Ubuntu shards run on a + pull request only with the `ci:full` label or a CI/harness/toolchain/dependency change. + Merged pull requests are verified in batches by a manual `dev` dispatch + (`MAINTAINERS.md`, "Batched merges"). Because `test changed` follows the module graph, + add `ci:full` for changes it cannot see (subprocesses, files read as data, generated output). - Full cross-platform (macOS/Windows) CI runs **only** via manual `workflow_dispatch` and on push to `main`/`preview` (release lines). - **Never** add a `dev` push trigger, or `pull_request` runs for macOS/Windows diff --git a/MAINTAINERS.md b/MAINTAINERS.md index 18741234818..db2798905e0 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -62,6 +62,16 @@ when a maintainer steps down. the ruleset does not check ancestry (see the note under the change log). - Pull requests require successful required CI checks before merge. Contributor pull requests normally require approval from at least one maintainer other than the author. +- **Batched merges.** A pull request's own CI is the fast lane by default: `gates` plus + `test changed` (the tests Bun's import graph reaches from the diff), not the four full + Ubuntu shards. The shards still run on a pull request that carries the `ci:full` label at + push time or touches the CI/test harness, fixtures, toolchain or dependency set + (`scripts/ci/select-test-lane.sh`). Add `ci:full` before pushing when a change reaches shared + contracts in ways an import graph cannot see (subprocesses, files read as data, generated + output). Merge green pull requests one at a time; after roughly every five merges, run + `gh workflow run ci.yml --ref dev` (the default `lane=all`). If that run is red, stop merging, + fix `dev` from the batch it covers, and resume once it is green. Always run it before a + promotion to `main`. - A current maintainer with GitHub `maintain` or `admin` access may explicitly integrate a pull request into `dev` without another maintainer's approval, including their own pull request. Record that choice and the exact-head verification in the pull-request description or comment. diff --git a/scripts/ci/run-changed-tests.sh b/scripts/ci/run-changed-tests.sh new file mode 100755 index 00000000000..9a54d3a5db9 --- /dev/null +++ b/scripts/ci/run-changed-tests.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Pull-request fast lane (`test changed` in ci.yml). +# +# Runs only the test files Bun's parsed import graph reaches from this pull +# request's diff, against the merge commit's first parent (the base tip). The +# four full Ubuntu shards are skipped on such a pull request; the manual dev +# dispatch after each merged batch (MAINTAINERS.md "Batched merges") and the +# nightly schedule are the backstop for dependencies the graph cannot follow. +# +# A diff that reaches no test file is not a failure here: `gates` already +# typechecked and linted it, and scripts/test.ts reports that case as an error +# only so a local run does not mistake "nothing selected" for "tested". +set -euo pipefail + +# One runner instead of four shards: allow a wide selection up to 25 minutes. +export OCX_TEST_MAIN_TIMEOUT_MS="${OCX_TEST_MAIN_TIMEOUT_MS:-1500000}" + +base="$(git rev-parse --verify --quiet 'HEAD^1^{commit}' || true)" +if [ -z "$base" ]; then + echo "::error::run-changed-tests.sh needs the merge commit's first parent; check out with fetch-depth: 2." >&2 + exit 1 +fi +echo "changed selection against ${base}" + +log="$(mktemp)" +trap 'rm -f "$log"' EXIT + +# storage-policy and api-usage keep their own fresh-process jobs (see ci.yml); +# keep them out of this shared pool for the same reason the shards do. +set +e +bun scripts/test.ts "--changed=${base}" \ + --path-ignore-patterns='**/api-storage-policy*.test.ts' \ + --path-ignore-patterns='**/api-storage.test.ts' \ + --path-ignore-patterns='**/api-usage.test.ts' 2>&1 | tee "$log" +status="${PIPESTATUS[0]}" +set -e + +if [ "$status" -ne 0 ] && grep -Eq 'no test files are affected|selected 0 tests across 0 files|No tests found!' "$log" \ + && ! grep -Eq '^\s*[1-9][0-9]* fail\b' "$log"; then + echo "::notice::No test file imports a changed module. gates covered this pull request; the batched dev run is the backstop." + status=0 +fi + +# Read-as-data guards the import graph never selects. The file-size ratchet is +# the one that most often goes red only after two green pull requests merge. +if ! bun scripts/test.ts ./tests/ci-workflows/file-size-ratchet.test.ts; then + status=1 +fi + +exit "$status" diff --git a/scripts/ci/select-test-lane.sh b/scripts/ci/select-test-lane.sh new file mode 100755 index 00000000000..b25836cb55f --- /dev/null +++ b/scripts/ci/select-test-lane.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# Choose a pull request's test lane (changes.outputs.fast in ci.yml). +# +# fast=true: the pull request runs `gates` plus `test changed` (the tests Bun's +# import graph reaches from the diff) instead of the four full Ubuntu shards. +# Merged batches are verified by a manual dev dispatch (MAINTAINERS.md +# "Batched merges"). fast=false keeps the four shards. It is false on every +# event other than pull_request, on an out-of-scope pull request, when the +# pull request carries the `ci:full` label, and when the diff touches an input +# the import graph cannot follow (the harness itself, golden fixtures read as +# data, the toolchain or the dependency set). +# +# Inputs (environment): EVENT_NAME, CI_SCOPE (true|false), FULL_LABEL +# (true|false), GITHUB_OUTPUT. The diff is treated strictly as data. +# Failure contract: invalid inputs or a failing git diff fail the job; a +# missing base parent cannot be scanned, so it selects the full lane. +set -euo pipefail + +: "${GITHUB_OUTPUT:?GITHUB_OUTPUT is required}" +event="${EVENT_NAME:-}" + +emit() { + printf 'fast=%s\n' "$1" >> "$GITHUB_OUTPUT" + printf 'test lane: %s (%s)\n' "$([ "$1" = true ] && echo fast || echo full)" "$2" + if [ "$event" = "pull_request" ] && [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + if [ "$1" = "true" ]; then + printf '**Fast test lane**: gates + `test changed`. Add the `ci:full` label and push to run the four full shards.\n' >> "$GITHUB_STEP_SUMMARY" + else + printf '**Full test lane** (%s).\n' "$2" >> "$GITHUB_STEP_SUMMARY" + fi + fi +} + +if [ "$event" != "pull_request" ]; then + emit false "event ${event:-unset} keeps the full suite" + exit 0 +fi + +for pair in "CI_SCOPE=${CI_SCOPE:-}" "FULL_LABEL=${FULL_LABEL:-}"; do + case "${pair#*=}" in + true|false) ;; + *) + printf '::error::%s was %q, expected true or false\n' "${pair%%=*}" "${pair#*=}" + exit 1 + ;; + esac +done + +if [ "$CI_SCOPE" != "true" ]; then + emit false "out of the ci scope" + exit 0 +fi +if [ "$FULL_LABEL" = "true" ]; then + emit false "ci:full label" + exit 0 +fi +if ! git rev-parse --verify --quiet 'HEAD^1^{commit}' >/dev/null; then + emit false "no base parent to diff" + exit 0 +fi + +changed="$(mktemp)" +trap 'rm -f "$changed"' EXIT +git diff --name-only 'HEAD^1' HEAD -- > "$changed" + +# Anchored extended regexes over repository-relative paths. +full_lane_paths='^(\.github/(workflows|actions|scripts)/|scripts/ci/|scripts/test(\.ts$|-[^/]*\.ts$)|tests/preload\.ts$|tests/fixtures/|bunfig\.toml$|tsconfig\.json$|package\.json$|bun\.lock$|gui/package\.json$|gui/bun\.lock$)' +status=0 +grep -Eq "$full_lane_paths" "$changed" || status=$? +case "$status" in + 0) + emit false "harness, fixture, toolchain or dependency change" + exit 0 + ;; + 1) ;; + *) + echo "::error::grep failed while selecting the test lane (exit $status)" >&2 + exit 1 + ;; +esac + +emit true "import-graph selection" diff --git a/tests/ci-workflows/ci-review-lanes.test.ts b/tests/ci-workflows/ci-review-lanes.test.ts index 09fc9956ad2..9b8eccfe05f 100644 --- a/tests/ci-workflows/ci-review-lanes.test.ts +++ b/tests/ci-workflows/ci-review-lanes.test.ts @@ -166,11 +166,21 @@ describe("CI review lanes", () => { const scopedCondition = "github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true'"; // platform-macos is native-gated and pinned to the native condition above, // so it must not be read against this ci-only condition. - for (const jobName of ["test", "storage-policy", "gates"]) { + for (const jobName of ["storage-policy", "gates"]) { const job = ci.jobs?.[jobName] as { needs?: string; if?: string } | undefined; expect(`${jobName}:${job?.needs}`).toBe(`${jobName}:changes`); expect(`${jobName}:${job?.if}`).toBe(`${jobName}:${scopedCondition}`); } + // The pull-request fast lane swaps the four full shards for `test changed`; every + // other event keeps the shards, and `fast` is only ever true on a pull request. + const shards = ci.jobs?.test as { needs?: string; if?: string } | undefined; + expect(shards?.needs).toBe("changes"); + expect(shards?.if).toBe("github.event_name != 'pull_request' || (needs.changes.outputs.ci == 'true' && needs.changes.outputs.fast != 'true')"); + const changedTests = ci.jobs?.["test-changed"] as { needs?: string; if?: string } | undefined; + expect(changedTests?.needs).toBe("changes"); + expect(changedTests?.if).toBe("github.event_name == 'pull_request' && needs.changes.outputs.fast == 'true'"); + expect((ci.jobs?.changes as { outputs?: Record } | undefined)?.outputs?.fast) + .toBe("${{ steps.lane.outputs.fast }}"); // The two smoke jobs keep the ci scope and, on a pull request only, also need their // own narrow filter. Every other event still requests them unconditionally. for (const [jobName, output] of [["keyring-smoke", "keyring"], ["docker-smoke", "docker"]] as const) { @@ -229,6 +239,8 @@ describe("CI review lanes", () => { for (const job of ["test", "gates", "storage-policy", "api-usage"]) { expect(enabled(job, event, lane, "true", "true", "true", "false", "false")).toBe(true); } + // Without the fast lane `test changed` never runs; the shards cover every event. + expect(enabled("test-changed", event, lane)).toBe(false); // macOS jobs never run on a pull request; they run after merge, on push. for (const job of ["platform-macos", "widget"]) { for (const scope of ["true", "false"]) { @@ -285,6 +297,8 @@ describe("CI review lanes", () => { (aggregate.needs ?? []).map(job => [job, { result: "success" }]), ); results["platform-windows"] = { result: "skipped" }; + // Only a fast-lane pull request runs `test changed`; a dispatch keeps the full shards. + results["test-changed"] = { result: "skipped" }; results["macos-control"] = { result: "skipped" }; results["docs-site-build"] = { result: "skipped" }; results["structure-gate"] = { result: "skipped" }; @@ -379,7 +393,7 @@ describe("Windows and nightly CI contracts", () => { checkouts += 1; } } - expect(checkouts).toBe(17); + expect(checkouts).toBe(18); }); test.skipIf(process.platform === "win32" || !Bun.which("jq"))("aggregate never requests Windows on a pull request and requests both diagnostic jobs nightly", async () => { diff --git a/tests/ci-workflows/ci-scope-gaps.test.ts b/tests/ci-workflows/ci-scope-gaps.test.ts index 64d74100a1e..75a5e1aef72 100644 --- a/tests/ci-workflows/ci-scope-gaps.test.ts +++ b/tests/ci-workflows/ci-scope-gaps.test.ts @@ -91,7 +91,7 @@ describe("an edit to the setup action alone", () => { // Keep runtime and test-runner role selection distinct even when package.json pins both to 1.4.4. // Check the active setup at each test command, so a later role switch cannot evade the guard. const runsBunTests = (step: Step): boolean => - /\bbun\s+test\b|\bbun\s+run\s+test(?::changed)?(?:\s|$)|\bbun\s+(?:run\s+)?scripts\/test\.ts\b|scripts\/ci\/run-bun-test-batches\.sh/.test( + /\bbun\s+test\b|\bbun\s+run\s+test(?::changed)?(?:\s|$)|\bbun\s+(?:run\s+)?scripts\/test\.ts\b|scripts\/ci\/run-(?:bun|changed)-tests?(?:-batches)?\.sh/.test( (step.run ?? "").split("\n").filter(line => !line.trimStart().startsWith("#")).join("\n"), ); const workflowJobs = new Map(readdirSync(repoPath(".github", "workflows")) diff --git a/tests/ci-workflows/ci-scope-reduction.test.ts b/tests/ci-workflows/ci-scope-reduction.test.ts index da389e9544f..6a8c9741f4b 100644 --- a/tests/ci-workflows/ci-scope-reduction.test.ts +++ b/tests/ci-workflows/ci-scope-reduction.test.ts @@ -248,6 +248,7 @@ type GateInputs = { lane: string; docker?: string; keyring?: string; + fast?: string; }; const PR_WITHOUT_NATIVE: GateInputs = { @@ -273,6 +274,7 @@ function gateEnvironment(inputs: GateInputs, results: Record): R CHANGES_STRUCTURE: inputs.structure, CHANGES_DOCKER: inputs.docker ?? "false", CHANGES_KEYRING: inputs.keyring ?? "false", + ...(inputs.fast === undefined ? {} : { CHANGES_FAST: inputs.fast }), LANE: inputs.lane, // `needs` serializes as an object per job, and the gate reads // `.value.result` out of it. Passing a flat name-to-string map makes jq @@ -489,6 +491,34 @@ describe("the aggregate gate", () => { expect(bad).toBe(""); }); + test.skipIf(cannotRunGate)("swaps the full shards for test changed only on a fast-lane pull request", () => { + const fast = runGate({ ...PR_WITHOUT_NATIVE, fast: "true" }, {}).expectations; + expect([fast.test, fast["test-changed"]]).toEqual(["not-requested", "requested"]); + for (const name of ["storage-policy", "api-usage", "gates"]) { + expect(`${name}:${fast[name]}`).toBe(`${name}:requested`); + } + // A full-lane pull request, and every other event, keeps the shards and never runs test changed. + for (const inputs of [ + { ...PR_WITHOUT_NATIVE, fast: "false" }, + PR_WITHOUT_NATIVE, + { ...PR_WITHOUT_NATIVE, eventName: "push", fast: "true" }, + { ...PR_WITHOUT_NATIVE, eventName: "workflow_dispatch", fast: "true" }, + ]) { + const other = runGate(inputs, {}).expectations; + expect(`${inputs.eventName}/${inputs.fast}:${other.test}:${other["test-changed"]}`) + .toBe(`${inputs.eventName}/${inputs.fast}:requested:not-requested`); + } + // An out-of-scope pull request requests neither, even if fast were set. + const outOfScope = runGate({ ...PR_WITHOUT_NATIVE, ci: "false", fast: "true" }, {}).expectations; + expect([outOfScope.test, outOfScope["test-changed"]]).toEqual(["not-requested", "not-requested"]); + // Results consistent with the fast lane pass; a shard that ran anyway fails by name. + const consistent = Object.fromEntries(Object.keys(jobs) + .filter(name => name !== "ci") + .map(name => [name, fast[name] === "requested" ? "success" : "skipped"])); + expect(runGate({ ...PR_WITHOUT_NATIVE, fast: "true" }, consistent).bad).toBe(""); + expect(runGate({ ...PR_WITHOUT_NATIVE, fast: "true" }, { ...consistent, test: "success" }).bad).toContain("test was not requested"); + }); + test.skipIf(cannotRunGate)("never requests macOS on a native pull request, but still requests desktop-shell", () => { const { expectations } = runGate({ ...PR_WITHOUT_NATIVE, native: "true" }, {}); for (const name of MACOS_JOBS) expect(`${name}:${expectations[name]}`).toBe(`${name}:not-requested`); diff --git a/tests/ci-workflows/ci-test-lane.test.ts b/tests/ci-workflows/ci-test-lane.test.ts new file mode 100644 index 00000000000..f81307d76ec --- /dev/null +++ b/tests/ci-workflows/ci-test-lane.test.ts @@ -0,0 +1,116 @@ +/** + * The pull-request test lane (scripts/ci/select-test-lane.sh). + * + * A fast-lane pull request runs `gates` plus `test changed` instead of the four + * full Ubuntu shards. The selector must keep the full lane for every other + * event, an out-of-scope pull request, the `ci:full` label, and any diff that + * touches an input the import graph cannot follow. + */ +import { describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { repoPath } from "../helpers/repo-root"; + +type Scenario = { + path?: string; + event?: string; + scope?: string; + label?: string; + orphan?: boolean; + expected?: "true" | "false"; + status?: number; +}; + +function select(scenario: Scenario): { status: number | null; fast: string | undefined; stderr: string } { + const directory = mkdtempSync(join(tmpdir(), "ocx-test-lane-")); + try { + const git = (...args: string[]) => { + const result = spawnSync("git", args, { + cwd: directory, encoding: "utf8", timeout: 5_000, + env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: process.platform === "win32" ? "NUL" : "/dev/null" }, + }); + expect(result.status, result.stderr).toBe(0); + }; + const commit = () => { + git("add", "."); + git("-c", "user.name=CI Fixture", "-c", "user.email=test@example.test", "-c", "commit.gpgsign=false", + "-c", "core.hooksPath=/dev/null", "commit", "--quiet", "--allow-empty", "-m", "fixture"); + }; + git("init", "--quiet"); + writeFileSync(join(directory, "seed.txt"), "seed\n"); + commit(); + if (!scenario.orphan) { + const path = join(directory, scenario.path ?? "src/example.ts"); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, "changed\n"); + commit(); + } + const output = join(directory, "output"); + writeFileSync(output, ""); + const result = spawnSync("bash", [repoPath("scripts", "ci", "select-test-lane.sh")], { + cwd: directory, encoding: "utf8", timeout: 5_000, + env: { + ...process.env, GITHUB_OUTPUT: output, GITHUB_STEP_SUMMARY: "", + EVENT_NAME: scenario.event ?? "pull_request", CI_SCOPE: scenario.scope ?? "true", FULL_LABEL: scenario.label ?? "false", + }, + }); + const fast = /^fast=(.*)$/m.exec(readFileSync(output, "utf8"))?.[1]; + return { status: result.status, fast, stderr: result.stderr }; + } finally { + removeTreeWithRetry(directory); + } +} + +describe.skipIf(process.platform === "win32")("pull-request test lane", () => { + test("an ordinary source or test change takes the fast lane", () => { + for (const path of ["src/router.ts", "tests/server/example.test.ts", "gui/src/App.tsx", "docs/notes.md"]) { + expect(`${path}:${select({ path }).fast}`).toBe(`${path}:true`); + } + }); + + test("harness, fixture, toolchain and dependency changes keep the full shards", () => { + for (const path of [ + ".github/workflows/ci.yml", ".github/actions/setup-project-bun/action.yml", ".github/scripts/x.mjs", + "scripts/ci/run-changed-tests.sh", "scripts/test.ts", "scripts/test-durations.ts", + "tests/preload.ts", "tests/fixtures/file-size-baseline.json", "bunfig.toml", "tsconfig.json", + "package.json", "bun.lock", "gui/package.json", "gui/bun.lock", + ]) { + expect(`${path}:${select({ path }).fast}`).toBe(`${path}:false`); + } + }); + + test("the label, the scope, other events and a missing parent keep the full shards", () => { + expect(select({ label: "true" }).fast).toBe("false"); + expect(select({ scope: "false" }).fast).toBe("false"); + expect(select({ orphan: true }).fast).toBe("false"); + for (const event of ["push", "workflow_dispatch", "schedule"]) { + expect(`${event}:${select({ event, scope: "invalid", label: "invalid" }).fast}`).toBe(`${event}:false`); + } + }); + + test("a malformed pull-request input fails the job instead of choosing a lane", () => { + for (const scenario of [{ scope: "" }, { label: "yes" }]) { + const result = select(scenario); + expect(result.status).toBe(1); + expect(result.fast).toBeUndefined(); + } + }); + + test("ci.yml wires the selector and the fast-lane job", () => { + const ci = Bun.YAML.parse(readFileSync(repoPath(".github", "workflows", "ci.yml"), "utf8")) as { + jobs: Record }> }>; + }; + const lane = ci.jobs.changes?.steps?.find(step => step.id === "lane"); + expect(lane?.run).toBe("bash scripts/ci/select-test-lane.sh"); + expect(lane?.env).toEqual({ + EVENT_NAME: "${{ github.event_name }}", + CI_SCOPE: "${{ steps.scope.outputs.ci }}", + FULL_LABEL: "${{ contains(github.event.pull_request.labels.*.name, 'ci:full') }}", + }); + const run = ci.jobs["test-changed"]?.steps?.map(step => step.run ?? "").join("\n") ?? ""; + expect(run).toContain("bash scripts/ci/run-changed-tests.sh"); + }); +}); From 2606f6f922d6c897836f4aafc0910f46293e5786 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 27 Oct 2026 10:47:12 +0900 Subject: [PATCH 101/101] feat(models): add GPT-6.1 Terra alongside GPT-5.6 Terra (non-Cursor providers, catalog, pricing, docs) (#7779) * feat(providers): add GPT-6.1 Terra alongside existing models * docs(models): document GPT-6.1 Terra across locales * docs(models): drop repeated Terra cross-link paragraphs and source comments * feat(codex): add GPT-6.1 Terra alongside existing native models Pin the supplied upstream row verbatim and register its native 500k/872k context, low-through-ultra ladder, Fast-only tiers and multi-agent v2. Keep GPT-5.6 Terra, defaults, configured-native template and roster version. Apply Terra's client minimum only to per-model absence evidence so unrelated roster success TTLs stay unchanged. Verify native, account-qualified and OpenAI-forward rows, Claude context and labels, and ultra-to-max wire folding. Keep Cursor and other lanes untouched. Validation: 613 affected tests and 38 layout/ratchet tests pass on Bun 1.4.4; typecheck, structure:check and privacy:scan pass. * fix(codex): keep GPT-6.1 Terra entitlement handling on the GPT-6.1 Sol rule * test(providers): give the Terra provider regression a unique layout name * feat(usage): add GPT-6.1 Terra pricing and metadata * fix(usage): price confirmed GPT-6.1 Terra Fast at the documented 2x * test(service): expect OpenRouter GPT-6.1 Terra service-tier capability --------- Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> --- .../docs/fr/guides/codex-app-models.md | 5 +- .../src/content/docs/fr/guides/providers.md | 5 +- .../fr/reference/configuration/providers.md | 12 +- .../content/docs/guides/codex-app-models.md | 7 +- .../src/content/docs/guides/providers.md | 13 +- .../docs/ja/guides/codex-app-models.md | 5 +- .../src/content/docs/ja/guides/providers.md | 5 +- .../ja/reference/configuration/providers.md | 12 +- .../docs/ko/guides/codex-app-models.md | 5 +- .../src/content/docs/ko/guides/providers.md | 5 +- .../ko/reference/configuration/providers.md | 12 +- .../docs/reference/configuration/agents.md | 2 +- .../docs/reference/configuration/providers.md | 13 +- .../docs/ru/guides/codex-app-models.md | 5 +- .../src/content/docs/ru/guides/providers.md | 5 +- .../ru/reference/configuration/providers.md | 12 +- .../docs/tr/guides/codex-app-models.md | 5 +- .../src/content/docs/tr/guides/providers.md | 5 +- .../tr/reference/configuration/providers.md | 12 +- .../docs/zh-cn/guides/codex-app-models.md | 5 +- .../content/docs/zh-cn/guides/providers.md | 5 +- .../reference/configuration/providers.md | 12 +- .../docs/zh-tw/guides/codex-app-models.md | 5 +- .../content/docs/zh-tw/guides/providers.md | 5 +- .../docs/zh-tw/guides/sub-agent-surface.md | 4 +- .../reference/configuration/providers.md | 12 +- scripts/model-metadata.source.json | 159 ++++++++++++++++ scripts/test-layout/layout.json | 8 +- src/adapters/devin/live-models.ts | 4 + src/adapters/kiro/reasoning.ts | 2 + src/codex/catalog/metadata.ts | 6 + src/codex/catalog/native-models.ts | 21 ++- src/codex/catalog/pinned-models.ts | 3 +- src/codex/data/roster-pinned-models.json | 178 +++++++++++++++++- src/codex/model-entitlements.ts | 2 +- src/generated/model-metadata.ts | 8 +- src/providers/codebuddy-models.ts | 6 + src/providers/kiro-models.ts | 3 + src/providers/registry/entries-core.ts | 6 +- src/providers/registry/entries-extended.ts | 21 ++- src/providers/registry/model-seeds.ts | 7 +- src/usage/expected-prices.ts | 21 ++- structure/catalog.md | 6 +- structure/providers-and-adapters.md | 1 + structure/providers/openai-accounts.md | 6 +- tests/ci-workflows/docs-gpt61-terra.test.ts | 54 ++++++ .../claude-context-windows.test.ts | 2 + .../claude-desktop-native-context.test.ts | 15 +- .../claude-model-default-labels.test.ts | 1 + .../catalog-native-max-context.test.ts | 1 + .../codex-catalog-ladders.test.ts | 2 +- tests/codex-integration/codex-catalog.test.ts | 2 +- .../gpt6-native-rows.test.ts | 6 +- .../gpt61-terra-rows.test.ts | 142 ++++++++++++++ .../native-model-toggle.test.ts | 2 +- tests/fixtures/test-layout-expected.json | 5 +- .../digitalocean-scaleway-provider.test.ts | 9 +- .../gpt61-terra-provider-rows.test.ts | 156 +++++++++++++++ tests/providers/modelflare-provider.test.ts | 4 +- .../provider-registry-parity.test.ts | 2 +- tests/providers/tokenlab-protocols.test.ts | 2 +- ...responses-native-ultra-effort-wire.test.ts | 4 +- tests/service/service-tier-capability.test.ts | 2 +- tests/usage/gpt61-terra-pricing.test.ts | 120 ++++++++++++ tests/usage/usage-cost.test.ts | 10 +- 65 files changed, 1102 insertions(+), 100 deletions(-) create mode 100644 tests/ci-workflows/docs-gpt61-terra.test.ts create mode 100644 tests/codex-integration/gpt61-terra-rows.test.ts create mode 100644 tests/providers/gpt61-terra-provider-rows.test.ts create mode 100644 tests/usage/gpt61-terra-pricing.test.ts diff --git a/docs-site/src/content/docs/fr/guides/codex-app-models.md b/docs-site/src/content/docs/fr/guides/codex-app-models.md index 0e69068a714..8e420079af1 100644 --- a/docs-site/src/content/docs/fr/guides/codex-app-models.md +++ b/docs-site/src/content/docs/fr/guides/codex-app-models.md @@ -138,11 +138,12 @@ approximation fondée sur un ancien modèle d'entrée. | Route | Identifiants du sélecteur et métadonnées du catalogue | | --- | --- | -| Connexion Codex (lignes qualifiées par compte désactivées) | Identifiants natifs non qualifiés comme `gpt-5.6-sol`, `gpt-5.6-terra` et `gpt-5.6-luna` ; Pool ou Direct est choisi avec `codexAccountMode`. Les lignes GPT-5.6 utilisent une fenêtre de catalogue de 372 000 jetons. | +| Connexion Codex (lignes qualifiées par compte désactivées) | Identifiants natifs non qualifiés comme `gpt-5.6-sol`, `gpt-5.6-terra` et `gpt-5.6-luna` ; Pool ou Direct est choisi avec `codexAccountMode`. Les lignes GPT-5.6 utilisent une fenêtre de catalogue de 372 000 jetons. `gpt-6.1-terra` : contexte par défaut 500,000 / étendu 872,000. | | Connexion Codex (lignes qualifiées par compte activées avec des sélecteurs admissibles) | Une ligne `/` par sélecteur admissible et modèle natif pris en charge ; chaque ligne utilise exclusivement le compte associé, et les lignes natives non qualifiées sont masquées dans le sélecteur. Les métadonnées natives et les fenêtres de contexte sont préservées. | | Connexion Codex (ligne Daybreak transférée explicitement) | `openai/gpt-daybreak-blue-latest` uniquement lorsque l'entrée `customModels` exacte est configurée sur le fournisseur canonique `openai`. Elle conserve l'identifiant Daybreak transmis et utilise l'instantané de capacités Sol épinglé (contexte de 372 000 jetons ; compactage automatique à 334 800 jetons). | -| OpenAI (clé API) | Exactement dix lignes avec espace de noms : `gpt-5.5`, `gpt-5.6`, Sol/Terra/Luna, les trois identifiants virtuels `*-pro` et les deux alias Daybreak (contexte de 1 050 000 jetons ; entrée maximale de 922 000 jetons pour les dix) | +| OpenAI (clé API) | Les modèles avec espace de noms comprennent `gpt-5.5`, `gpt-5.6`, Sol/Terra/Luna, les identifiants virtuels `*-pro` et les alias Daybreak (contexte de 1,050,000 ; entrée maximale de 922,000). `openai-apikey/gpt-6.1-terra` ajoute un contexte de 1,050,000 et une sortie maximale de 128,000. | | OpenRouter | `openrouter/openai/gpt-5.6-sol`, `openrouter/openai/gpt-5.6-terra`, `openrouter/openai/gpt-5.6-luna` (1 050 000) | +| OpenRouter | `openrouter/openai/gpt-6.1-terra` (1,050,000) | | Cursor | Le repli statique comprend `cursor/gpt-5.6-sol`, `cursor/gpt-5.6-terra` et `cursor/gpt-5.6-luna` (1 000 000), ainsi que des lignes ordinaires/rapides pour Grok 4.5, 4.6 et 4.7 (500 000) ; 4.6 et 4.7 ajoutent `xhigh`, et la découverte dynamique propre au compte détermine quelles lignes restent visibles. | | xAI | La découverte dynamique fait autorité. Le catalogue de secours comprend `xai/grok-4.6` et `xai/grok-4.7` et utilise `xai/grok-4.5` par défaut ; les trois ont une fenêtre de 500 000 jetons. Grok 4.6 et 4.7 proposent `low` / `medium` / `high` / `xhigh` (valeur amont par défaut : `high`), tandis que Grok 4.5 s’arrête à `high`. | diff --git a/docs-site/src/content/docs/fr/guides/providers.md b/docs-site/src/content/docs/fr/guides/providers.md index 59b7a72c46b..91cc03aaabe 100644 --- a/docs-site/src/content/docs/fr/guides/providers.md +++ b/docs-site/src/content/docs/fr/guides/providers.md @@ -624,6 +624,9 @@ maintenir leur visibilité même lorsque les catalogues en direct ne sont pas en | Connexion Codex (Pool ou Direct) | `gpt-5.6-*` | 372 000 | | OpenAI (clé API) | `openai-apikey/gpt-5.6-*` plus `*-pro` | 1 050 000 (entrée maximale de 922 000) | | OpenRouter | `openrouter/openai/gpt-5.6-sol`, `openrouter/openai/gpt-5.6-terra`, `openrouter/openai/gpt-5.6-luna` | 1 050 000 | +| Codex | `gpt-6.1-terra` | 500,000 / 872,000 | +| OpenAI API | `openai-apikey/gpt-6.1-terra` | 1,050,000 (128,000 max output) | +| OpenRouter | `openrouter/openai/gpt-6.1-terra` | 1,050,000 | | Cursor | `cursor/gpt-5.6-sol`, `cursor/gpt-5.6-terra`, `cursor/gpt-5.6-luna` | 1 000 000 | Les entrées natives GPT-5.6 conservent les niveaux de raisonnement fixés en amont — Luna propose par exemple @@ -644,7 +647,7 @@ reste une passerelle à clé ou jeton d'abonnement sur son point de terminaison **Cloudflare AI Gateway** exige que les identifiants de votre compte et de votre passerelle figurent dans l'URL. Copilot présente un catalogue qui utilise plusieurs protocoles : ces modèles (`gpt-5.3-codex`, `gpt-5.4`, -`gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`) rejettent +`gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-6.1-sol`, `gpt-6.1-terra`, `gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`) rejettent `/chat/completions` pour le trafic d'agent. opencodex route donc ces modèles sur l'API Responses par défaut, tandis que tous les autres modèles Copilot restent sur Chat Completions. L'ordre de priorité est le suivant : verrouillage explicite du protocole → entrée [`modelAdapters`](/fr/reference/configuration/providers/) définie 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 031ce647b2b..a46a4f89add 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -12,6 +12,16 @@ En l’absence de fenêtre déclarée ou configurée, OpenCodex utilise les mét Cette estimation est désactivée par défaut : activez-la par fournisseur avec `inferContextWindow: true`. Sans ce réglage, une fenêtre absente utilise le Context cap activé, puis 128k. +## GPT-6.1 Terra + +[GPT-6.1 Terra](https://openai.com/index/introducing-gpt-6-1-terra/), sorti le 26 octobre 2026, succède à GPT-5.6 Terra. `gpt-6.1-terra` reste disponible aux côtés de `gpt-5.6-terra`, sans changement de modèle par défaut ni de liste de sous-agents. GitHub Copilot utilise Responses ; OpenRouter propose `openai/gpt-6.1-terra` avec un contexte de 1,050,000 tokens et des tarifs de $1.00 en entrée / $5.00 en sortie par million de tokens. + +Codex natif : contexte par défaut de 500,000 tokens, plafond étendu de 872,000, effort par défaut `low` et niveaux `low`, `medium`, `high`, `xhigh`, `max`, `ultra`. Le niveau natif `ultra` est envoyé sous la forme `max` sur le réseau. La ligne native propose uniquement Fast. + +[API OpenAI](https://developers.openai.com/api/docs/models/gpt-6.1-terra) : contexte de 1,050,000 tokens, sortie maximale de 128,000 ; entrée texte/image, sortie texte ; connaissances arrêtées au 2026-06-01. Efforts : `none`, `low`, `medium` (par défaut), `high`, `xhigh`, `max`. Points de terminaison : `v1/responses` et `v1/chat/completions` ; les appels d’outils nécessitent Responses. + +Par million de tokens : $1.00 en entrée, $0.05 en lecture du cache, $1.25 en écriture du cache (30 minutes), $5.00 en sortie. Au-delà de 272,000 tokens d’entrée, toute la requête est facturée à 2× pour l’entrée et le cache, et 1.5× pour la sortie : respectivement $2.00 / $0.10 / $2.50 / $7.50. Le mode Fast coûte 2×. + ## Sélection des modèles à l’inscription Une nouvelle connexion sans OAuth attend une liste de modèles fiable avant de les exposer. Si l’onglet Models contient au moins 20 lignes distinctes, tous les interrupteurs de modèles sont initialement OFF ; le fournisseur reste ACTIVE. Les connexions utilisant effectivement OAuth ou la connexion ChatGPT conservent leurs valeurs par défaut. @@ -133,7 +143,7 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix | `modelReasoningEfforts?` | `Record` | Libellés propres à chaque modèle. Une liste vide masque le contrôle de l'effort. Comme pour `reasoningEfforts`, chaque échelle configurée avec l'adaptateur `google` déclare la capacité `thinkingLevel` ; les requêtes directes et Vertex sans image utilisent le chemin Gemini à plat, tandis que Cloud Code Assist l'envoie dans son enveloppe de requête. | | `modelSupportsReasoningSummaries?` | `Record` | Définissez un modèle sur `false` pour arrêter la publicité des résumés et supprimer les champs de livraison du résumé. | | `modelReasoningSummaryDelivery?` | `Record` | Énumération de livraison des réponses par modèle ; réécrit un champ de livraison existant. | -| `modelAdapters?` | `Record` | Remplacement du protocole `openai-chat` ou `openai-responses` par modèle pour les passerelles multiprotocoles. Les entrées explicites priment sur les valeurs par défaut du registre. Le préréglage OpenCode Go sélectionne Responses pour `gpt-5.6-luna` tout en laissant les modèles apparentés sur leurs protocoles documentés ; DeepSeek peut sélectionner Responses natif pour `deepseek-v4-flash` ; GitHub Copilot déclare des valeurs par défaut limitées à Responses pour ces modèles (`gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`), car ces modèles rejettent `/chat/completions` pour le trafic des agents. Les modèles sans valeur intégrée par défaut, comme `gpt-5.4-nano`, peuvent être activés ici. Les services en amont à protocole unique et le transfert canonique ChatGPT rejettent ces remplacements. | +| `modelAdapters?` | `Record` | Remplacement du protocole `openai-chat` ou `openai-responses` par modèle pour les passerelles multiprotocoles. Les entrées explicites priment sur les valeurs par défaut du registre. Le préréglage OpenCode Go sélectionne Responses pour `gpt-5.6-luna` tout en laissant les modèles apparentés sur leurs protocoles documentés ; DeepSeek peut sélectionner Responses natif pour `deepseek-v4-flash` ; GitHub Copilot déclare des valeurs par défaut limitées à Responses pour ces modèles (`gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-6.1-sol`, `gpt-6.1-terra`, `gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`), car ces modèles rejettent `/chat/completions` pour le trafic des agents. Les modèles sans valeur intégrée par défaut, comme `gpt-5.4-nano`, peuvent être activés ici. Les services en amont à protocole unique et le transfert canonique ChatGPT rejettent ces remplacements. | | Activation Responses xAI (tableau de bord) | interrupteur | Pour `xai` uniquement, définit ou efface atomiquement les entrées `modelAdapters` de `grok-4.5` et `grok-4.6`. Une seule entrée apparaît comme un état mixte jusqu’à la prochaine écriture. Les autres remplacements et le comportement des tiers restent inchangés. Grok 4.7 utilise Responses par défaut sur OAuth via le registre et peut passer à Chat avec une entrée explicite `modelAdapters["grok-4.7"] = "openai-chat"`. | | `xaiResponsesXSearch?` | `boolean` | Désactivé par défaut. Sur une destination xAI Responses, ajoute la déclaration `x_search` hébergée par le fournisseur uniquement lorsqu’un outil `web_search` actif subsiste après la normalisation finale de la requête. Les déclarations existantes ne sont pas dupliquées, les sélecteurs `tool_choice`/`allowed_tools` de l’appelant ne sont jamais élargis, et cette option est distincte des options `search.xSearch` du service auxiliaire de recherche web. | | `modelPreferHostedTools?` | `Record` | Activation explicite par modèle exact pour les passerelles Responses hors transfert qui réservent un espace de noms aux outils hébergés. Seul `["image_generation"]` est actuellement accepté ; le modèle correspondant doit utiliser le protocole `openai-responses` et prendre en charge cet outil hébergé. Le proxy supprime les déclarations clientes `image_gen` en conflit et réécrit leurs sélecteurs afin de préserver le choix d'outil de l'appelant. Pour les modèles virtuels `-pro` de l'API OpenAI, l'identifiant public sélectionné est comparé en premier et l'identifiant résolu du modèle de base sur le protocole sert de repli. `modelAdapters` résout d'abord l'identifiant public, puis celui de base ; la seconde résolution détermine le protocole final. Les autres modèles conservent le comportement normal des alias. | diff --git a/docs-site/src/content/docs/guides/codex-app-models.md b/docs-site/src/content/docs/guides/codex-app-models.md index 2a04b4dcc19..4da93918113 100644 --- a/docs-site/src/content/docs/guides/codex-app-models.md +++ b/docs-site/src/content/docs/guides/codex-app-models.md @@ -221,11 +221,12 @@ metadata instead of an older-template approximation. | Route | Picker ids and catalog metadata | | --- | --- | -| Codex login (account-qualified rows disabled) | Bare native ids such as `gpt-5.6-sol`, `gpt-5.6-terra`, and `gpt-5.6-luna`; Pool or Direct is selected through `codexAccountMode`. GPT-5.6 rows use a 922,000-token catalog window. | +| Codex login (account-qualified rows disabled) | Bare native ids such as `gpt-5.6-sol`, `gpt-5.6-terra`, and `gpt-5.6-luna`; Pool or Direct is selected through `codexAccountMode`. GPT-5.6 rows use a 922,000-token catalog window. `gpt-6.1-terra`: 500,000 default / 872,000 long context. | | Codex login (account-qualified rows enabled with eligible selectors) | One `/` row per eligible selector and supported native model; each row uses only its mapped account, and bare native rows are hidden from the picker. Native metadata and context windows are preserved. | | Codex login (explicit Daybreak forward row) | `openai/gpt-daybreak-blue-latest` only when the exact `customModels` row is configured on the canonical `openai` provider. It keeps the Daybreak wire id and uses the pinned Sol capability snapshot (922,000 context; 829,800 automatic compaction). | -| OpenAI (API key) | Exactly ten namespaced rows: `gpt-5.5`, `gpt-5.6`, Sol/Terra/Luna, the three `*-pro` virtual ids, and the two Daybreak aliases (1,050,000 context; 922,000 max input for all ten) | +| OpenAI (API key) | Namespaced models include `gpt-5.5`, `gpt-5.6`, Sol/Terra/Luna, the `*-pro` virtual ids and the Daybreak aliases (1,050,000 context; 922,000 max input). `openai-apikey/gpt-6.1-terra` adds 1,050,000 context and 128,000 max output. | | OpenRouter | `openrouter/openai/gpt-5.6-sol`, `openrouter/openai/gpt-5.6-terra`, `openrouter/openai/gpt-5.6-luna` (922,000) | +| OpenRouter | `openrouter/openai/gpt-6.1-terra` (1,050,000) | | Cursor | Static fallback includes `cursor/gpt-5.6-sol`, `cursor/gpt-5.6-terra`, and `cursor/gpt-5.6-luna` (1,000,000), plus regular/Fast rows for Grok 4.5, 4.6, and 4.7 (500,000); 4.6 and 4.7 add `xhigh`, and live account discovery decides which rows remain visible. | | xAI | Live discovery is authoritative. The fallback catalog includes `xai/grok-4.6` and `xai/grok-4.7` and defaults to `xai/grok-4.5`; all three have 500,000-token windows. Grok 4.6 and 4.7 expose `low` / `medium` / `high` / `xhigh` (upstream default: `high`), while Grok 4.5 stops at `high`. | @@ -279,7 +280,7 @@ On the wire, routed adapters map or clamp unsupported tiers. For older native mo ladder stops at `xhigh`, `nativeEffortClamp` maps a direct `max` or an `ultra` selection to `xhigh` (for example, GPT-5.5). Sol, Terra, and Luna have a real `max` rung. The ChatGPT backend itself accepts `none` through `max`, so a native ChatGPT request never forwards `ultra` as-is: -native models with a real `max` rung receive `max`, including Astra and GPT-6.1 Sol whose ladders +native models with a real `max` rung receive `max`, including Astra, GPT-6.1 Sol and GPT-6.1 Terra whose ladders list `ultra`, while older models such as GPT-5.5 receive `xhigh` as above. This applies to `/v1/responses` and `/v1/chat/completions` alike. The native clamp does not apply to routed model IDs: a routed OpenAI Chat passthrough can copy a declared `reasoning_effort: "ultra"` to its provider unchanged, diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 0238f96993b..f2d5f813a86 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -850,7 +850,7 @@ Each model then uses the request format TokenLab declares for it | Models | Codex (Responses clients) | Chat clients | Claude Code (Anthropic clients) | | --- | --- | --- | --- | -| `gpt-6-astra`, `gpt-6.1-sol`, `gpt-6-sol`, `gpt-6-luna`, `grok-4.7`, `deepseek-v4.1-flash`, `deepseek-v4-pro`, `kimi-k3`, `glm-5.3` | [Responses](https://tokenlab.sh/docs/en/api-reference/responses/create-response) | Chat Completions | Chat Completions | +| `gpt-6-astra`, `gpt-6.1-sol`, `gpt-6.1-terra`, `gpt-6-sol`, `gpt-6-luna`, `grok-4.7`, `deepseek-v4.1-flash`, `deepseek-v4-pro`, `kimi-k3`, `glm-5.3` | [Responses](https://tokenlab.sh/docs/en/api-reference/responses/create-response) | Chat Completions | Chat Completions | | `claude-*` | [Messages](https://tokenlab.sh/docs/en/api-reference/messages/create-message) | Messages | Messages | | Every other model, including `gemini-3.8-flash` | Chat Completions | Chat Completions | Chat Completions | @@ -871,9 +871,11 @@ picker and marks it as a sponsor, and nothing else about routing or defaults cha **Modelflare** is an OpenAI-compatible relay at `https://modelflare.dev/v1`. Select **Modelflare** in `ocx init` or the dashboard's **Add provider** picker, or run `ocx provider add modelflare`, and give it your key directly or as the reference `${MODELFLARE_API_KEY}` so the key stays in your -environment. The preset uses Chat Completions and seeds four models that +environment. The preset uses Chat Completions. The following existing models were verified with chat, streaming and tool calls: `glm-5.3-flash` (the default), -`claude-sonnet-5-5`, `gpt-6.1-sol` and `deepseek-v4-flash`. You can add other Chat-compatible +`claude-sonnet-5-5`, `gpt-6.1-sol` and `deepseek-v4-flash`. +`gpt-6.1-terra` is a preemptive seed mirroring Sol; it has not been inference-verified here. +You can add other Chat-compatible Modelflare models as custom models. Image and video models use separate Modelflare endpoints and do not work through this Chat preset. An ordinary Modelflare key is bound to one routing group and can call only that group's models, so use a Smart key for the full seeded set, or pass @@ -1584,6 +1586,9 @@ visible even while live catalogs lag: | Codex login (Pool or Direct) | `gpt-5.6-*` | 922,000 | | OpenAI (API key) | `openai-apikey/gpt-5.6-*` plus `*-pro` | 922,000 (922,000 max input) | | OpenRouter | `openrouter/openai/gpt-5.6-sol`, `openrouter/openai/gpt-5.6-terra`, `openrouter/openai/gpt-5.6-luna` | 922,000 | +| Codex | `gpt-6.1-terra` | 500,000 / 872,000 | +| OpenAI API | `openai-apikey/gpt-6.1-terra` | 1,050,000 (128,000 max output) | +| OpenRouter | `openrouter/openai/gpt-6.1-terra` | 1,050,000 | | Cursor | `cursor/gpt-5.6-sol`, `cursor/gpt-5.6-terra`, `cursor/gpt-5.6-luna` | 1,000,000 | The native GPT-5.6 entries preserve the pinned upstream reasoning ladders (for example, Luna has @@ -1630,7 +1635,7 @@ seconds per call, including pacing and body reads. These are OpenCodex safeguard GitHub limits; oversized catalogs, slow negotiation or a truncated intent excerpt can affect compatibility. For named selections, Copilot fronts a mixed-wire catalog: the following models (`gpt-5.3-codex`, `gpt-5.4`, -`gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`) reject +`gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-6.1-sol`, `gpt-6.1-terra`, `gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`) reject `/chat/completions` for agent traffic, so opencodex routes those models over the Responses API by built-in default while every other Copilot model stays on chat completions. The precedence is: hard wire pin → your explicit diff --git a/docs-site/src/content/docs/ja/guides/codex-app-models.md b/docs-site/src/content/docs/ja/guides/codex-app-models.md index 63d62a27364..ef99c820f3a 100644 --- a/docs-site/src/content/docs/ja/guides/codex-app-models.md +++ b/docs-site/src/content/docs/ja/guides/codex-app-models.md @@ -52,10 +52,11 @@ visibility = "list" |ルート |ピッカー ID とカタログのメタデータ | | --- | --- | -| Codex ログイン (account-qualified 行が無効) | `gpt-5.6-sol`、`gpt-5.6-terra`、`gpt-5.6-luna` などの bare native id を表示し、`codexAccountMode` に従って Pool または Direct を使用します。GPT-5.6 行のカタログ ウィンドウは 922,000 トークンです。 | +| Codex ログイン (account-qualified 行が無効) | `gpt-5.6-sol`、`gpt-5.6-terra`、`gpt-5.6-luna` などの bare native id を表示し、`codexAccountMode` に従って Pool または Direct を使用します。GPT-5.6 行のカタログ ウィンドウは 922,000 トークンです。 `gpt-6.1-terra`:既定 500,000 / 長文 872,000。 | | Codex ログイン (account-qualified 行が有効で、有効な selector あり) | 有効な selector とサポート対象 native model の各組み合わせに `/` 行を表示します。各行は対応付けられたアカウントだけを使用し、bare native 行はピッカーで非表示になります。Native metadata と context window は保持されます。 | -| OpenAI (API キー) |正確に 8 つの名前空間行: `gpt-5.5`、`gpt-5.6`、Sol/Terra/Luna、および 3 つの `*-pro` 仮想 ID (コンテキスト 922,000、8 つすべての最大入力 922,000) | +| OpenAI (API キー) | 名前空間付きモデルには `gpt-5.5`、`gpt-5.6`、Sol/Terra/Luna、`*-pro` 仮想 ID、Daybreak エイリアスが含まれます(コンテキスト 1,050,000、最大入力 922,000)。`openai-apikey/gpt-6.1-terra` はコンテキスト 1,050,000、最大出力 128,000 です。 | |オープンルーター | `openrouter/openai/gpt-5.6-sol`、`openrouter/openai/gpt-5.6-terra`、`openrouter/openai/gpt-5.6-luna` (922,000) | +| OpenRouter | `openrouter/openai/gpt-6.1-terra` (1,050,000) | | Cursor | 静的フォールバックには `cursor/gpt-5.6-sol`、`cursor/gpt-5.6-terra`、`cursor/gpt-5.6-luna` (1,000,000) と、Grok 4.5 / 4.6 / 4.7 の通常・Fast 行 (500,000) が含まれます。4.6 と 4.7 は `xhigh` も公開し、ライブアカウントの検出によって表示される行が決まります。 | | xAI | xAI のライブ検出が優先されます。フォールバックには `xai/grok-4.6` と `xai/grok-4.7` が含まれ、デフォルトは `xai/grok-4.5` です。3 モデルともコンテキストは 500,000 トークンです。Grok 4.6 と 4.7 は `low` / `medium` / `high` / `xhigh`(上流のデフォルトは `high`)を提供し、Grok 4.5 は `high` までです。 | diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index 39fec41d0ca..2c5920a8c38 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -465,6 +465,9 @@ Sol/Terra/Luna をフォールバックリストに入れています。 | Codex ログイン(Pool または Direct) | `gpt-5.6-*` | 922,000 | | OpenAI (API キー) | `openai-apikey/gpt-5.6-*` と `*-pro` | 922,000 (max input 922,000) | | OpenRouter | `openrouter/openai/gpt-5.6-sol`、`openrouter/openai/gpt-5.6-terra`、`openrouter/openai/gpt-5.6-luna` | 922,000 | +| Codex | `gpt-6.1-terra` | 500,000 / 872,000 | +| OpenAI API | `openai-apikey/gpt-6.1-terra` | 1,050,000 (128,000 max output) | +| OpenRouter | `openrouter/openai/gpt-6.1-terra` | 1,050,000 | | Cursor | `cursor/gpt-5.6-sol`、`cursor/gpt-5.6-terra`、`cursor/gpt-5.6-luna` | 1,000,000 | ネイティブ GPT-5.6 項目は固定の上流推論段階に従います。例えば Luna には @@ -484,7 +487,7 @@ Amazon Bedrock ネイティブ API のような、これらの実装のいずれ Gateway** は URL にアカウント + ゲートウェイ ID を埋める必要があります。 Copilot は混在 wire カタログを提供します。モデル(`gpt-5.3-codex`、`gpt-5.4`、 -`gpt-5.4-mini`、`gpt-5.5`、`gpt-5.6-luna`、`gpt-5.6-sol`、`gpt-5.6-terra`、`gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`)はエージェント +`gpt-5.4-mini`、`gpt-5.5`、`gpt-5.6-luna`、`gpt-5.6-sol`、`gpt-5.6-terra`、 `gpt-6.1-sol`、 `gpt-6.1-terra`、 `gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`)はエージェント 通信の `/chat/completions` を拒否するため、opencodex はこれらのモデルを組み込みデフォルトで Responses API 経由にルーティングし、他の Copilot モデルはすべて chat completions のままです。 優先順位は次のとおりです: ハード wire ピン → 明示的な 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 b9b4816488e..fc82f872333 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -11,6 +11,16 @@ description: プロバイダー エントリ、認証、エンドポイント、 この推定は既定で無効です。プロバイダーごとに `inferContextWindow: true` で有効にします。未設定の場合、ウィンドウがないモデルは有効な Context cap、次に 128k を使います。 +## GPT-6.1 Terra + +[GPT-6.1 Terra](https://openai.com/index/introducing-gpt-6-1-terra/) は GPT-5.6 Terra の後継として 2026年10月26日に公開されました。`gpt-6.1-terra` は `gpt-5.6-terra` と並行して利用でき、既定モデルやサブエージェントのモデル一覧は変更しません。GitHub Copilot では Responses を使用します。OpenRouter の ID は `openai/gpt-6.1-terra` で、コンテキストは 1,050,000、100万トークンあたり入力 $1.00、出力 $5.00 です。 + +ネイティブ Codex:既定コンテキスト 500,000、長文コンテキスト上限 872,000、既定 effort は `low`。段階は `low`、`medium`、`high`、`xhigh`、`max`、`ultra` です。ネイティブの `ultra` は通信時に `max` として送信します。ネイティブ行の高速モードは Fast のみです。 + +[OpenAI API](https://developers.openai.com/api/docs/models/gpt-6.1-terra):コンテキスト 1,050,000、最大出力 128,000。入力はテキストと画像、出力はテキスト。知識カットオフは 2026-06-01 です。effort は `none`、`low`、`medium`(既定)、`high`、`xhigh`、`max`。エンドポイントは `v1/responses` と `v1/chat/completions` ですが、ツール呼び出しには Responses が必要です。 + +100万トークンあたり入力 $1.00、キャッシュ入力 $0.05、キャッシュ書き込み $1.25(30分)、出力 $5.00。入力が 272,000 トークンを超える場合、リクエスト全体の入力・キャッシュは 2×、出力は 1.5× となり、それぞれ $2.00 / $0.10 / $2.50 / $7.50 です。Fast モードは 2× です。 + ## 初回登録時のモデル選択 新しい非 OAuth 接続では、信頼できるモデル一覧の取得が完了するまでモデルの公開を保留します。Models タブの重複しないモデル行が20個以上なら、モデルのスイッチをすべて OFF にします。プロバイダー自体は ACTIVE のままです。実際の認証方式が OAuth または ChatGPT ログインなら既定値を維持します。 @@ -126,7 +136,7 @@ account を削除しても mapping は保持され、同じ id を再追加す | `modelReasoningEfforts?` | `Record` |モデルごとのラベル。空のリストは努力制御を非表示にします。 | | `modelSupportsReasoningSummaries?` | `Record` |モデルを `false` に設定して、概要の広告を停止し、概要配信フィールドを削除します。 | | `modelReasoningSummaryDelivery?` | `Record` |モデルごとの応答配信列挙型。既存の配信フィールドを書き換えます。 | -| `modelAdapters?` | `Record` | 混合配線ゲートウェイのモデルごとの `openai-chat` または `openai-responses` 配線オーバーライド。明示的なエントリはレジストリのデフォルトを破ります。DeepSeek のプリセットは `deepseek-v4-flash` のネイティブ Responses を選択でき、GitHub Copilot は モデル (`gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`) を Responses 専用デフォルトとして宣言します。これらのモデルはエージェント トラフィックで `/chat/completions` を拒否するためです。`gpt-5.4-nano` のようなビルトイン デフォルトのないモデルはここでオプトインできます。単線アップストリーム ピンと正規の ChatGPT 転送はオーバーライドを拒否します。 | +| `modelAdapters?` | `Record` | 混合配線ゲートウェイのモデルごとの `openai-chat` または `openai-responses` 配線オーバーライド。明示的なエントリはレジストリのデフォルトを破ります。DeepSeek のプリセットは `deepseek-v4-flash` のネイティブ Responses を選択でき、GitHub Copilot は モデル (`gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-6.1-sol`, `gpt-6.1-terra`, `gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`) を Responses 専用デフォルトとして宣言します。これらのモデルはエージェント トラフィックで `/chat/completions` を拒否するためです。`gpt-5.4-nano` のようなビルトイン デフォルトのないモデルはここでオプトインできます。単線アップストリーム ピンと正規の ChatGPT 転送はオーバーライドを拒否します。 | | xAI Responses オプトイン(ダッシュボード) | スイッチ | `xai` のみで、`grok-4.5` と `grok-4.6` の `modelAdapters` エントリを原子的に設定または削除します。片方だけの場合は、次のスイッチ操作で両方が正規化されるまで混合状態を表示します。他のオーバーライドと tier 動作は変わりません。 Grok 4.7 は OAuth でレジストリの既定値により Responses を使用し、明示的な `modelAdapters["grok-4.7"] = "openai-chat"` で Chat に切り替えられます。 | | `xaiResponsesXSearch?` | `boolean` | デフォルトでは無効です。xAI Responses の宛先では、最終的なリクエスト正規化後もライブの `web_search` ツールが残っている場合にのみ、プロバイダーがホストする `x_search` 宣言を追加します。既存の宣言は重複させず、呼び出し元の `tool_choice` / `allowed_tools` セレクターの範囲を拡張することもありません。また、これは `search.xSearch` オプションを持つウェブ検索サイドカーとは別です。 | | `modelPreferHostedTools?` | `Record` | hosted tool namespace を予約する非 forward Responses gateway 向けの完全一致モデル opt-in。現在は `["image_generation"]` のみを受け付けます。一致したモデルは `openai-responses` wire を使い、その hosted tool をサポートする必要があります。競合するクライアント `image_gen` 宣言を除去し、呼び出し元の tool choice を維持するため selector も書き換えます。OpenAI API の仮想 `-pro` モデルでは、まず選択した公開 ID に一致させ、解決後のベース wire-model ID をフォールバックとして使用します。`modelAdapters` は公開 ID、次にベース ID の順に解決し、後者の結果が最終 wire を決めます。未設定のモデルは通常の alias 動作を維持します。 | diff --git a/docs-site/src/content/docs/ko/guides/codex-app-models.md b/docs-site/src/content/docs/ko/guides/codex-app-models.md index 60b38ca4c9c..40c716687da 100644 --- a/docs-site/src/content/docs/ko/guides/codex-app-models.md +++ b/docs-site/src/content/docs/ko/guides/codex-app-models.md @@ -104,11 +104,12 @@ GPT-5.6에만 사용합니다. 오래된 템플릿으로 근사하지 않고 모 | 경로 | 선택기 id와 카탈로그 메타데이터 | | --- | --- | -| Codex 로그인(계정 한정 선택기 행 비활성) | `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` 같은 bare native id를 표시하고 `codexAccountMode`에 따라 Pool 또는 Direct를 사용합니다. GPT-5.6 행의 카탈로그 창은 922,000토큰입니다. | +| Codex 로그인(계정 한정 선택기 행 비활성) | `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` 같은 bare native id를 표시하고 `codexAccountMode`에 따라 Pool 또는 Direct를 사용합니다. GPT-5.6 행의 카탈로그 창은 922,000토큰입니다. `gpt-6.1-terra`: 기본 500,000 / 장문 872,000. | | Codex 로그인(계정 한정 선택기 행 활성, 유효한 selector 있음) | 유효한 selector와 지원되는 native model의 각 조합마다 `/` 행을 표시합니다. 각 행은 매핑된 계정만 사용하며 bare native 행은 선택기에서 숨깁니다. Native metadata와 context window는 보존됩니다. | | Codex 로그인(명시적 Daybreak forward 행) | canonical `openai` provider에 정확한 `customModels` 항목이 있을 때만 `openai/gpt-daybreak-blue-latest`를 표시합니다. Daybreak wire id를 유지하고 고정된 Sol capability snapshot(컨텍스트 922,000; 자동 압축점 922,000)을 사용합니다. | -| OpenAI(API key) | 정확히 열 개의 네임스페이스 행: `gpt-5.5`, `gpt-5.6`, Sol/Terra/Luna, 세 개의 `*-pro` 가상 id, 두 Daybreak 별칭 (모두 컨텍스트 922,000; 최대 입력 922,000) | +| OpenAI(API key) | 네임스페이스 모델에는 `gpt-5.5`, `gpt-5.6`, Sol/Terra/Luna, `*-pro` 가상 id, Daybreak 별칭이 포함됩니다(컨텍스트 1,050,000; 최대 입력 922,000). `openai-apikey/gpt-6.1-terra`는 컨텍스트 1,050,000, 최대 출력 128,000입니다. | | OpenRouter | `openrouter/openai/gpt-5.6-sol`, `openrouter/openai/gpt-5.6-terra`, `openrouter/openai/gpt-5.6-luna` (922,000) | +| OpenRouter | `openrouter/openai/gpt-6.1-terra` (1,050,000) | | Cursor | 정적 폴백에는 `cursor/gpt-5.6-sol`, `cursor/gpt-5.6-terra`, `cursor/gpt-5.6-luna` (1,000,000)와 Grok 4.5/4.6/4.7의 일반·Fast 항목(500,000)이 들어갑니다. 4.6과 4.7은 `xhigh`도 노출하며, 실시간 계정 탐색이 어떤 항목을 계속 보일지 정합니다. | | xAI | 실시간 탐색이 기준입니다. 폴백 카탈로그에는 `xai/grok-4.6`과 `xai/grok-4.7`이 포함되며 기본값은 `xai/grok-4.5`입니다. 세 모델 모두 컨텍스트 창은 500,000입니다. Grok 4.6과 4.7은 `low` / `medium` / `high` / `xhigh`(업스트림 기본값: `high`)를 제공하고, Grok 4.5는 `high`까지만 제공합니다. | diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 4cb0c110238..e7fe1c3c387 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -471,6 +471,9 @@ Sol/Terra/Luna를 폴백 목록에 넣어 둡니다. | Codex 로그인(Pool 또는 Direct) | `gpt-5.6-*` | 922,000 | | OpenAI (API key) | `openai-apikey/gpt-5.6-*`와 `*-pro` | 922,000 (max input 922,000) | | OpenRouter | `openrouter/openai/gpt-5.6-sol`, `openrouter/openai/gpt-5.6-terra`, `openrouter/openai/gpt-5.6-luna` | 922,000 | +| Codex | `gpt-6.1-terra` | 500,000 / 872,000 | +| OpenAI API | `openai-apikey/gpt-6.1-terra` | 1,050,000 (128,000 max output) | +| OpenRouter | `openrouter/openai/gpt-6.1-terra` | 1,050,000 | | Cursor | `cursor/gpt-5.6-sol`, `cursor/gpt-5.6-terra`, `cursor/gpt-5.6-luna` | 1,000,000 | 네이티브 GPT-5.6 항목은 고정된 업스트림 reasoning 단계를 그대로 따릅니다. 예를 들어 Luna에는 @@ -491,7 +494,7 @@ Amazon Bedrock 네이티브 API처럼 이 구현 중 어느 것과도 맞지 않 Gateway**는 URL에 계정 + 게이트웨이 id를 채워야 합니다. Copilot은 혼합 wire 카탈로그를 제공합니다. 모델(`gpt-5.3-codex`, `gpt-5.4`, -`gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`)은 에이전트 +`gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-6.1-sol`, `gpt-6.1-terra`, `gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`)은 에이전트 트래픽에 대해 `/chat/completions`를 거부하므로 opencodex는 이 모델들을 내장 기본값으로 Responses API를 통해 라우팅하고, 다른 Copilot 모델은 모두 chat completions를 유지합니다. 우선순위는 하드 wire 핀 → 명시적 [`modelAdapters`](/ko/reference/configuration/providers/) 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 0fc58dc0558..6a0a43ef73b 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -11,6 +11,16 @@ description: 공급자 항목, 인증, 엔드포인트, 모델 카탈로그, 할 이 추정은 기본적으로 꺼져 있습니다. 공급자별로 `inferContextWindow: true`를 설정해야 켜집니다. 설정하지 않으면 창이 없는 모델은 활성 Context cap, 그다음 128k를 사용합니다. +## GPT-6.1 Terra + +[GPT-6.1 Terra](https://openai.com/index/introducing-gpt-6-1-terra/)는 GPT-5.6 Terra의 후속 모델로 2026년 10월 26일 출시되었습니다. `gpt-6.1-terra`는 `gpt-5.6-terra`와 함께 제공하며 기본 모델이나 하위 에이전트 모델 목록은 바꾸지 않습니다. GitHub Copilot에서는 Responses를 사용합니다. OpenRouter ID는 `openai/gpt-6.1-terra`이며 컨텍스트는 1,050,000, 100만 토큰당 입력 $1.00 / 출력 $5.00입니다. + +네이티브 Codex: 기본 컨텍스트 500,000, 장문 상한 872,000, 기본 effort `low`, 단계는 `low`, `medium`, `high`, `xhigh`, `max`, `ultra`입니다. 네이티브 `ultra`는 전송 시 `max`로 변환합니다. 네이티브 행의 고속 모드는 Fast만 제공합니다. + +[OpenAI API](https://developers.openai.com/api/docs/models/gpt-6.1-terra): 컨텍스트 1,050,000, 최대 출력 128,000, 텍스트·이미지 입력과 텍스트 출력, 지식 기준일 2026-06-01입니다. effort는 `none`, `low`, `medium`(기본), `high`, `xhigh`, `max`입니다. 엔드포인트는 `v1/responses`와 `v1/chat/completions`이며 도구 호출에는 Responses가 필요합니다. + +100만 토큰당 입력 $1.00, 캐시 입력 $0.05, 캐시 쓰기 $1.25(30분), 출력 $5.00입니다. 입력이 272,000 토큰을 초과하면 요청 전체에 입력·캐시 2×, 출력 1.5×를 적용하여 각각 $2.00 / $0.10 / $2.50 / $7.50입니다. Fast 모드는 2×입니다. + ## 처음 등록할 때의 모델 선택 신규 비-OAuth 연결은 신뢰할 수 있는 모델 목록을 확보할 때까지 모델 노출을 보류합니다. Models 탭의 중복 없는 모델 행이 20개 이상이면 모델 스위치를 모두 OFF로 설정합니다. 프로바이더는 활성 상태를 유지합니다. 실제 인증 방식이 OAuth나 ChatGPT 로그인인 연결은 기존 기본값을 유지합니다. @@ -126,7 +136,7 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | `modelReasoningEfforts?` | `Record` | 모델별 레이블입니다. 빈 목록이면 effort 제어를 숨깁니다. | | `modelSupportsReasoningSummaries?` | `Record` | 모델을 `false`로 두면 summary 광고를 멈추고 summary 전달 필드를 제거합니다. | | `modelReasoningSummaryDelivery?` | `Record` | 모델별 Responses 전달 enum입니다. 기존 delivery 필드를 다시 씁니다. | -| `modelAdapters?` | `Record` | 혼합 와이어 게이트웨이를 위한 모델별 `openai-chat` 또는 `openai-responses` 와이어 재정의입니다. 명시적 항목이 레지스트리 기본값보다 우선합니다. DeepSeek 프리셋은 `deepseek-v4-flash`에 네이티브 Responses를 선택할 수 있고, GitHub Copilot은 모델(`gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`)을 Responses 전용 기본값으로 선언합니다. 이 모델들은 에이전트 트래픽에서 `/chat/completions`를 거부하기 때문입니다. `gpt-5.4-nano`처럼 기본값이 없는 모델은 여기서 직접 옵트인할 수 있습니다. 단일 와이어 상위 항목과 정식 ChatGPT forward는 재정의를 거부합니다. | +| `modelAdapters?` | `Record` | 혼합 와이어 게이트웨이를 위한 모델별 `openai-chat` 또는 `openai-responses` 와이어 재정의입니다. 명시적 항목이 레지스트리 기본값보다 우선합니다. DeepSeek 프리셋은 `deepseek-v4-flash`에 네이티브 Responses를 선택할 수 있고, GitHub Copilot은 모델(`gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-6.1-sol`, `gpt-6.1-terra`, `gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`)을 Responses 전용 기본값으로 선언합니다. 이 모델들은 에이전트 트래픽에서 `/chat/completions`를 거부하기 때문입니다. `gpt-5.4-nano`처럼 기본값이 없는 모델은 여기서 직접 옵트인할 수 있습니다. 단일 와이어 상위 항목과 정식 ChatGPT forward는 재정의를 거부합니다. | | xAI Responses 옵트인(대시보드) | 스위치 | `xai`에서만 `grok-4.5`와 `grok-4.6`의 `modelAdapters` 항목을 원자적으로 설정하거나 지웁니다. 한 항목만 있으면 다음 스위치 쓰기가 둘을 정규화할 때까지 혼합 상태로 표시됩니다. 다른 재정의와 티어 동작은 바뀌지 않습니다. Grok 4.7은 OAuth에서 레지스트리 와이어 기본값으로 Responses를 사용하며, 명시적 `modelAdapters["grok-4.7"] = "openai-chat"` 항목으로 Chat으로 전환할 수 있습니다. | | `xaiResponsesXSearch?` | `boolean` | 기본적으로 비활성화됩니다. xAI Responses 대상에서는 최종 요청 정규화 후에도 실제 `web_search` 도구가 남아 있을 때만 공급자가 호스팅하는 `x_search` 선언을 추가합니다. 기존 선언은 중복하지 않고, 호출자의 `tool_choice`/`allowed_tools` 선택기 범위를 확장하지 않으며, 웹 검색 사이드카의 `search.xSearch` 옵션과는 별개입니다. | | `modelPreferHostedTools?` | `Record` | hosted tool namespace를 예약하는 non-forward Responses gateway용 정확한 모델 ID opt-in입니다. 현재 `["image_generation"]`만 허용하며, 일치하는 모델은 `openai-responses` wire를 사용하고 해당 hosted tool을 지원해야 합니다. 충돌하는 클라이언트 `image_gen` 선언을 제거하고 호출자의 tool choice를 유지하도록 selector도 다시 씁니다. OpenAI API 가상 `-pro` 모델은 선택한 공개 ID를 먼저 일치시키고, 해석된 기본 wire-model ID를 대체값으로 사용합니다. `modelAdapters`는 공개 ID를 먼저, 그 다음 기본 ID를 해석하며, 두 번째 결과가 최종 wire를 결정합니다. 설정하지 않은 모델은 일반 alias 동작을 유지합니다. | diff --git a/docs-site/src/content/docs/reference/configuration/agents.md b/docs-site/src/content/docs/reference/configuration/agents.md index d03750291a2..1b6791280cd 100644 --- a/docs-site/src/content/docs/reference/configuration/agents.md +++ b/docs-site/src/content/docs/reference/configuration/agents.md @@ -385,7 +385,7 @@ Each selected destination resolves its own pin, then applicable caps and wire no Compaction requests are exempt. `none` means effort omission and provider-default behavior, not guaranteed reasoning disablement. For canonical OpenAI forwarding, the final model's supported effort list takes precedence: -`gpt-6.1-sol` starts at `low`, so an incoming `none` or `minimal` is sent as `low`, including +`gpt-6.1-sol` and `gpt-6.1-terra` start at `low`, so an incoming `none` or `minimal` is sent as `low`, including after a compaction model switch or combo failover. `GET /api/effort-caps` includes the map. `PUT /api/effort-caps` accepts `modelPinnedEfforts` diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 9ec4cb46ad5..808d56d2c23 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -33,6 +33,16 @@ have a 60-second upload deadline, a 128 MiB input cap and at most two simultaneo provider pacing/concurrency still applies. Oversized text or unconvertible images still need compaction; this transport repair does not extend the model's context window. +## GPT-6.1 Terra + +[GPT-6.1 Terra](https://openai.com/index/introducing-gpt-6-1-terra/) was released on October 26, 2026 as the successor to GPT-5.6 Terra. `gpt-6.1-terra` is available alongside `gpt-5.6-terra`; no default model or sub-agent roster changes. GitHub Copilot uses Responses; OpenRouter lists `openai/gpt-6.1-terra` with 1,050,000 context and $1.00 input / $5.00 output per 1M tokens. + +Native Codex: default context 500,000, long-context ceiling 872,000, default effort `low`, ladder `low`, `medium`, `high`, `xhigh`, `max`, `ultra`. Native `ultra` is sent as `max` on the wire. The native row offers Fast only. + +[OpenAI API](https://developers.openai.com/api/docs/models/gpt-6.1-terra): context 1,050,000, maximum output 128,000; text/image input and text output; knowledge cutoff 2026-06-01. Efforts: `none`, `low`, `medium` (default), `high`, `xhigh`, `max`. Endpoints: `v1/responses` and `v1/chat/completions`; tool calling requires Responses. + +Per 1M tokens: $1.00 input, $0.05 cached input, $1.25 cache write (30 minutes), $5.00 output. Above 272,000 input tokens, the whole request uses 2× input/cache and 1.5× output: $2.00 / $0.10 / $2.50 / $7.50 respectively. Fast mode costs 2×. + ## Context windows on custom providers When a custom provider omits a model's context window, OpenCodex can infer it from the bundled @@ -354,7 +364,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `modelSuppressSyntheticMax?` | `Record` | Catalog-only per-model switch. `true` prevents OpenCodex from adding a missing synthetic `max` rung while retaining a provider-declared `max` and continuing to add `ultra` to reasoning-capable ladders. A preserved row that already contains `max` keeps it until healthy discovery rebuilds that row, because the catalog does not persist whether an older `max` was synthetic. If a configured default names a suppressed missing `max`, the catalog falls back to the highest real rung below it. Codex uses the same ladder membership for the picker and explicit `spawn_agent` effort validation, so an explicit `max` spawn can fail client-side before proxy clamping; `ultra` remains the supported harness path. | | `modelSupportsReasoningSummaries?` | `Record` | Set a model to `false` to stop advertising summaries and strip summary-delivery fields. | | `modelReasoningSummaryDelivery?` | `Record` | Per-model Responses delivery enum; rewrites an existing delivery field. | -| `modelAdapters?` | `Record` | Per-model `openai-chat` or `openai-responses` wire override for mixed-wire gateways. Explicit entries beat registry defaults. The OpenCode Go preset selects Responses for `gpt-5.6-luna` while leaving sibling models on their documented wires; DeepSeek can select native Responses for `deepseek-v4-flash`; Alibaba Token Plan (Beijing) pins `qwen3.8-flash`, `qwen3.7-plus`, and `glm-5.3` to its native Responses API for Responses inbound only (live-verified on that gateway, including plaintext reasoning replay; Chat and Anthropic inbound stay on Chat, and the rest of the family can be opted in here); and GitHub Copilot declares Responses-only defaults for the following models (`gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`) because those models reject `/chat/completions` for agent traffic. Models without a built-in default (for example `gpt-5.4-nano`) can be opted in here. Single-wire upstream pins and canonical ChatGPT forward reject overrides. | +| `modelAdapters?` | `Record` | Per-model `openai-chat` or `openai-responses` wire override for mixed-wire gateways. Explicit entries beat registry defaults. The OpenCode Go preset selects Responses for `gpt-5.6-luna` while leaving sibling models on their documented wires; DeepSeek can select native Responses for `deepseek-v4-flash`; Alibaba Token Plan (Beijing) pins `qwen3.8-flash`, `qwen3.7-plus`, and `glm-5.3` to its native Responses API for Responses inbound only (live-verified on that gateway, including plaintext reasoning replay; Chat and Anthropic inbound stay on Chat, and the rest of the family can be opted in here); and GitHub Copilot declares Responses-only defaults for the following models (`gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-6.1-sol`, `gpt-6.1-terra`, `gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`) because those models reject `/chat/completions` for agent traffic. Models without a built-in default (for example `gpt-5.4-nano`) can be opted in here. Single-wire upstream pins and canonical ChatGPT forward reject overrides. | | xAI Chat Completions (dashboard / CLI) | switch | Grok 4.5/4.6 OAuth Responses requests default to Responses. Existing Chat overrides are migrated once on upgrade; later Chat choices are preserved. Turn on to select Chat for both models, off to select Responses. CLI: `ocx provider edit xai --xai-chat on` or `--xai-chat off` (running proxy required). Mixed means only one model currently uses Chat. Other overrides and tier policy stay unchanged. API-key and translated Chat/Anthropic defaults are unchanged. Grok 4.7 defaults to Responses on OAuth through its registry wire default and can use Chat through an explicit `modelAdapters["grok-4.7"] = "openai-chat"` override. | | `xaiResponsesXSearch?` | `boolean` | Disabled by default. On an xAI Responses destination, append the provider-hosted `x_search` declaration only when a live `web_search` tool survives final request normalization. Existing declarations are not duplicated, caller `tool_choice`/`allowed_tools` selectors are never widened, and this is separate from the web-search sidecar's `search.xSearch` options. | | `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. | @@ -916,6 +926,7 @@ OpenAI-backed model slugs: - `openai/gpt-5.6-sol` - `openai/gpt-5.6-terra` +- `openai/gpt-6.1-terra` - `openai/gpt-5.6-luna` `anthropic/claude-sonnet-5` and undeclared OpenRouter models remain unclassified. A provider-level diff --git a/docs-site/src/content/docs/ru/guides/codex-app-models.md b/docs-site/src/content/docs/ru/guides/codex-app-models.md index 366b3b44046..04a4381f8ee 100644 --- a/docs-site/src/content/docs/ru/guides/codex-app-models.md +++ b/docs-site/src/content/docs/ru/guides/codex-app-models.md @@ -84,10 +84,11 @@ per-model identity и метаданные вместо приближения | Маршрут | Id в селекторе и метаданные каталога | | --- | --- | -| Вход Codex (строки с указанием аккаунта выключены) | Bare native-id, например `gpt-5.6-sol`, `gpt-5.6-terra` и `gpt-5.6-luna`; Pool или Direct выбирается через `codexAccountMode`. У строк GPT-5.6 окно каталога 922 000 токенов. | +| Вход Codex (строки с указанием аккаунта выключены) | Bare native-id, например `gpt-5.6-sol`, `gpt-5.6-terra` и `gpt-5.6-luna`; Pool или Direct выбирается через `codexAccountMode`. У строк GPT-5.6 окно каталога 922 000 токенов. `gpt-6.1-terra`: контекст 500,000 по умолчанию / 872,000 расширенный. | | Вход Codex (строки с указанием аккаунта включены и есть подходящие селекторы) | По одной строке `/` для каждой пары подходящего селектора и поддерживаемой нативной модели; каждая строка использует только сопоставленный аккаунт, а bare native-строки скрыты из picker'а. Нативные метаданные и окна контекста сохраняются. | -| OpenAI (API key) | Ровно восемь namespaced-строк: `gpt-5.5`, `gpt-5.6`, Sol/Terra/Luna и три виртуальных id `*-pro` (контекст 922,000; максимум входа 922,000 у всех восьми) | +| OpenAI (API key) | Модели с пространством имён включают `gpt-5.5`, `gpt-5.6`, Sol/Terra/Luna, виртуальные id `*-pro` и псевдонимы Daybreak (контекст 1,050,000; максимум входа 922,000). `openai-apikey/gpt-6.1-terra` добавляет контекст 1,050,000 и максимум выхода 128,000. | | OpenRouter | `openrouter/openai/gpt-5.6-sol`, `openrouter/openai/gpt-5.6-terra`, `openrouter/openai/gpt-5.6-luna` (922,000) | +| OpenRouter | `openrouter/openai/gpt-6.1-terra` (1,050,000) | | Cursor | Статический fallback включает `cursor/gpt-5.6-sol`, `cursor/gpt-5.6-terra` и `cursor/gpt-5.6-luna` (1,000,000), а также обычные/Fast-строки Grok 4.5, 4.6 и 4.7 (500,000). Для 4.6 и 4.7 доступен ещё `xhigh`; какие строки останутся видимыми, решает live-discovery аккаунта. | | xAI | Live-discovery имеет приоритет. Fallback-каталог включает `xai/grok-4.6` и `xai/grok-4.7`, а моделью по умолчанию остаётся `xai/grok-4.5`; у всех трёх окно 500,000 токенов. Grok 4.6 и 4.7 поддерживают `low` / `medium` / `high` / `xhigh` (upstream-default: `high`), а Grok 4.5 — только до `high`. | diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index 66c7357be33..49ff52970f9 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -490,6 +490,9 @@ GPT-5.6 Sol/Terra/Luna заранее внесены в резервные сп | Вход Codex (Pool или Direct) | `gpt-5.6-*` | 922,000 | | OpenAI (API key) | `openai-apikey/gpt-5.6-*` плюс `*-pro` | 922,000 (макс. вход 922,000) | | OpenRouter | `openrouter/openai/gpt-5.6-sol`, `openrouter/openai/gpt-5.6-terra`, `openrouter/openai/gpt-5.6-luna` | 922,000 | +| Codex | `gpt-6.1-terra` | 500,000 / 872,000 | +| OpenAI API | `openai-apikey/gpt-6.1-terra` | 1,050,000 (128,000 max output) | +| OpenRouter | `openrouter/openai/gpt-6.1-terra` | 1,050,000 | | Cursor | `cursor/gpt-5.6-sol`, `cursor/gpt-5.6-terra`, `cursor/gpt-5.6-luna` | 1,000,000 | Нативные записи GPT-5.6 сохраняют закреплённые вышестоящие шкалы уровней рассуждений (например, у @@ -510,7 +513,7 @@ Assist), `azure` / `azure-openai`, `kiro` и `cursor`. Проприетарны точке. **Cloudflare AI Gateway** требует подставить в URL id аккаунта и шлюза. Copilot предоставляет каталог со смешанными проводами: модели (`gpt-5.3-codex`, -`gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`) +`gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-6.1-sol`, `gpt-6.1-terra`, `gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`) отклоняют `/chat/completions` для агентного трафика, поэтому opencodex по умолчанию маршрутизирует эти модели через Responses API, а все остальные модели Copilot остаются на chat completions. Приоритет: жёсткий wire-пин → явная запись 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 98f181a7bb6..bba82ea2c7b 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -12,6 +12,16 @@ description: Записи провайдеров, аутентификация, Эта оценка по умолчанию выключена: включите её для провайдера параметром `inferContextWindow: true`. Без него модель без окна использует включённый Context cap, затем 128k. +## GPT-6.1 Terra + +[GPT-6.1 Terra](https://openai.com/index/introducing-gpt-6-1-terra/) вышла 26 октября 2026 года как преемница GPT-5.6 Terra. `gpt-6.1-terra` доступна наряду с `gpt-5.6-terra`; модель по умолчанию и список подагентов не меняются. GitHub Copilot использует Responses. OpenRouter предлагает `openai/gpt-6.1-terra` с контекстом 1,050,000 и ценой $1.00 за вход / $5.00 за выход на миллион токенов. + +Нативный Codex: контекст по умолчанию 500,000, расширенный предел 872,000, effort по умолчанию `low`; уровни `low`, `medium`, `high`, `xhigh`, `max`, `ultra`. Нативный `ultra` передаётся по сети как `max`. Нативная строка предлагает только Fast. + +[OpenAI API](https://developers.openai.com/api/docs/models/gpt-6.1-terra): контекст 1,050,000, максимальный выход 128,000; текст и изображения на входе, текст на выходе; срез знаний 2026-06-01. Effort: `none`, `low`, `medium` (по умолчанию), `high`, `xhigh`, `max`. Эндпоинты: `v1/responses` и `v1/chat/completions`; вызов инструментов требует Responses. + +За миллион токенов: $1.00 вход, $0.05 чтение кеша, $1.25 запись кеша (30 минут), $5.00 выход. При входе свыше 272,000 токенов весь запрос тарифицируется с множителем 2× для входа и кеша и 1.5× для выхода: соответственно $2.00 / $0.10 / $2.50 / $7.50. Режим Fast стоит 2×. + ## Выбор моделей при первой регистрации Новое подключение без OAuth не публикует модели до получения достоверного списка. Если в Models не менее 20 уникальных строк моделей, все переключатели моделей изначально OFF, но сам провайдер остаётся ACTIVE. Подключения, фактически использующие OAuth или вход ChatGPT, сохраняют настройки по умолчанию. @@ -139,7 +149,7 @@ cross-route credential fallback не существует. Строки API GPT- | `modelReasoningEfforts?` | `Record` | Label'ы по отдельным моделям. Пустой список скрывает управление effort. | | `modelSupportsReasoningSummaries?` | `Record` | Установите `false` для модели, чтобы перестать рекламировать summary и вырезать поля доставки summary. | | `modelReasoningSummaryDelivery?` | `Record` | Responses delivery enum по моделям; переписывает уже существующее поле delivery. | -| `modelAdapters?` | `Record` | Wire-override по модели для `openai-chat` или `openai-responses` в gateway с несколькими wire-форматами. Явные записи имеют приоритет над default'ами registry; preset DeepSeek может выбирать native Responses для `deepseek-v4-flash`, а GitHub Copilot объявляет Responses-only default'ы для моделей (`gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`), потому что эти модели отклоняют `/chat/completions` для агентного трафика. Модели без встроенного default'а (например, `gpt-5.4-nano`) можно включить здесь. Single-wire upstream pin'ы и canonical ChatGPT forward override не принимают. | +| `modelAdapters?` | `Record` | Wire-override по модели для `openai-chat` или `openai-responses` в gateway с несколькими wire-форматами. Явные записи имеют приоритет над default'ами registry; preset DeepSeek может выбирать native Responses для `deepseek-v4-flash`, а GitHub Copilot объявляет Responses-only default'ы для моделей (`gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-6.1-sol`, `gpt-6.1-terra`, `gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`), потому что эти модели отклоняют `/chat/completions` для агентного трафика. Модели без встроенного default'а (например, `gpt-5.4-nano`) можно включить здесь. Single-wire upstream pin'ы и canonical ChatGPT forward override не принимают. | | Opt-in xAI Responses (панель) | переключатель | Только для `xai`: атомарно задаёт или удаляет записи `modelAdapters` для `grok-4.5` и `grok-4.6`. Одна запись отображается как смешанное состояние до следующего переключения. Остальные override и поведение tier не меняются. Grok 4.7 по умолчанию использует Responses на OAuth через настройку реестра и может перейти на Chat через явную запись `modelAdapters["grok-4.7"] = "openai-chat"`. | | `xaiResponsesXSearch?` | `boolean` | По умолчанию отключено. Для назначения xAI Responses декларация `x_search`, размещённая у провайдера, добавляется только тогда, когда действующий инструмент `web_search` сохраняется после окончательной нормализации запроса. Существующие декларации не дублируются, селекторы вызывающей стороны `tool_choice`/`allowed_tools` никогда не расширяются, и эта настройка не связана с параметрами `search.xSearch` сайдкара веб-поиска. | | `modelPreferHostedTools?` | `Record` | Opt-in для точного model ID в non-forward Responses gateway, который резервирует namespace hosted tool. Сейчас допускается только `["image_generation"]`; совпавшая модель должна использовать wire `openai-responses` и поддерживать этот hosted tool. Прокси удаляет конфликтующие клиентские объявления `image_gen` и переписывает их selectors, сохраняя caller tool choice. Для виртуальных моделей OpenAI API `-pro` сначала сопоставляется выбранный публичный ID, а затем в качестве fallback используется ID базовой wire-модели. `modelAdapters` сначала разрешается по публичному ID, затем по базовому ID; второй результат определяет итоговый wire. Остальные модели сохраняют обычное alias-поведение. | diff --git a/docs-site/src/content/docs/tr/guides/codex-app-models.md b/docs-site/src/content/docs/tr/guides/codex-app-models.md index e270fea3574..6cf1ba16587 100644 --- a/docs-site/src/content/docs/tr/guides/codex-app-models.md +++ b/docs-site/src/content/docs/tr/guides/codex-app-models.md @@ -152,11 +152,12 @@ meta verileri sağladığı GPT-5.6 için kullanılır. | Rota | Seçici kimlikleri ve katalog meta verileri | | --- | --- | -| Codex girişi (hesap nitelikli satırlar devre dışı) | `gpt-5.6-sol`, `gpt-5.6-terra` ve `gpt-5.6-luna` gibi yalın yerel kimlikler; Pool veya Direct `codexAccountMode` aracılığıyla seçilir. GPT-5.6 satırları 922.000 tokenlik bir katalog penceresi kullanır. | +| Codex girişi (hesap nitelikli satırlar devre dışı) | `gpt-5.6-sol`, `gpt-5.6-terra` ve `gpt-5.6-luna` gibi yalın yerel kimlikler; Pool veya Direct `codexAccountMode` aracılığıyla seçilir. GPT-5.6 satırları 922.000 tokenlik bir katalog penceresi kullanır. `gpt-6.1-terra`: varsayılan bağlam 500,000 / uzun bağlam 872,000. | | Codex girişi (uygun seçicilerle hesap nitelikli satırlar etkin) | Uygun seçici ve desteklenen yerel model başına bir `/` satırı; her satır yalnızca eşlenen hesabını kullanır ve yalın yerel satırlar seçiciden gizlenir. Yerel meta veriler ve bağlam pencereleri korunur. | | Codex girişi (açık Daybreak iletme satırı) | Yalnızca tam `customModels` satırı kurallı `openai` sağlayıcısında yapılandırıldığında `openai/gpt-daybreak-blue-latest`. Daybreak hat kimliğini korur ve sabitlenmiş Sol yetenek anlık görüntüsünü kullanır (922.000 bağlam; 829.800 otomatik sıkıştırma). | -| OpenAI (API anahtarı) | Tam olarak on ad alanlı satır: `gpt-5.5`, `gpt-5.6`, Sol/Terra/Luna, üç `*-pro` sanal kimliği ve iki Daybreak takma adı (onunun tümü için 922.000 bağlam; 922.000 maksimum girdi) | +| OpenAI (API anahtarı) | Ad alanlı modeller arasında `gpt-5.5`, `gpt-5.6`, Sol/Terra/Luna, `*-pro` sanal kimlikleri ve Daybreak takma adları bulunur (1,050,000 bağlam; 922,000 maksimum girdi). `openai-apikey/gpt-6.1-terra` için bağlam 1,050,000, maksimum çıktı 128,000’dır. | | OpenRouter | `openrouter/openai/gpt-5.6-sol`, `openrouter/openai/gpt-5.6-terra`, `openrouter/openai/gpt-5.6-luna` (922.000) | +| OpenRouter | `openrouter/openai/gpt-6.1-terra` (1,050,000) | | Cursor | Statik geri dönüş `cursor/gpt-5.6-sol`, `cursor/gpt-5.6-terra` ve `cursor/gpt-5.6-luna` (1.000.000) ile Grok 4.5, 4.6 ve 4.7 için normal/Fast satırları (500.000) içerir; 4.6 ve 4.7 ayrıca `xhigh` sunar. Canlı hesap keşfi hangi satırların görünür kalacağını belirler. | | xAI | Canlı keşif yetkilidir. Geri dönüş kataloğu `xai/grok-4.6` ve `xai/grok-4.7` içerir; varsayılan model `xai/grok-4.5` olarak kalır. Üçünün de bağlam penceresi 500.000 tokendir. Grok 4.6 ve 4.7 `low` / `medium` / `high` / `xhigh` (yukarı akış varsayılanı: `high`) sunarken, Grok 4.5 `high` ile durur. | diff --git a/docs-site/src/content/docs/tr/guides/providers.md b/docs-site/src/content/docs/tr/guides/providers.md index 76de995c6d3..58f0f8ee2f1 100644 --- a/docs-site/src/content/docs/tr/guides/providers.md +++ b/docs-site/src/content/docs/tr/guides/providers.md @@ -665,6 +665,9 @@ tohumlanmıştır: | Codex girişi (Pool veya Direct) | `gpt-5.6-*` | 922.000 | | OpenAI (API anahtarı) | `openai-apikey/gpt-5.6-*` artı `*-pro` | 922.000 (922.000 maksimum girdi) | | OpenRouter | `openrouter/openai/gpt-5.6-sol`, `openrouter/openai/gpt-5.6-terra`, `openrouter/openai/gpt-5.6-luna` | 922.000 | +| Codex | `gpt-6.1-terra` | 500,000 / 872,000 | +| OpenAI API | `openai-apikey/gpt-6.1-terra` | 1,050,000 (128,000 max output) | +| OpenRouter | `openrouter/openai/gpt-6.1-terra` | 1,050,000 | | Cursor | `cursor/gpt-5.6-sol`, `cursor/gpt-5.6-terra`, `cursor/gpt-5.6-luna` | 1.000.000 | Yerel GPT-5.6 girdileri sabitlenmiş yukarı akış akıl yürütme merdivenlerini @@ -688,7 +691,7 @@ anahtar/abonelik belirteci ağ geçidi olarak kalır. **Cloudflare AI Gateway**, URL'ye doldurulan hesap + ağ geçidi kimliklerinize ihtiyaç duyar. Copilot karma hatlı bir katalog sunar: modeller (`gpt-5.3-codex`, `gpt-5.4`, -`gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`) ajan +`gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-6.1-sol`, `gpt-6.1-terra`, `gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`) ajan trafiği için `/chat/completions`'ı reddeder, bu nedenle opencodex yerleşik varsayılan olarak bu modelleri Responses API üzerinden yönlendirirken diğer tüm Copilot modelleri sohbet tamamlamalarında kalır. Öncelik sırası: sabit hat 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 15a8160c080..0242a1b4754 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -12,6 +12,16 @@ Model penceresi bildirilmemiş veya yapılandırılmamışsa sırasıyla Azure m Bu tahmin varsayılan olarak kapalıdır; sağlayıcı başına `inferContextWindow: true` ile açılır. Ayarlanmazsa penceresi olmayan model etkin Context cap'i, ardından 128k'yı kullanır. +## GPT-6.1 Terra + +[GPT-6.1 Terra](https://openai.com/index/introducing-gpt-6-1-terra/), GPT-5.6 Terra’nın halefi olarak 26 Ekim 2026’da yayımlandı. `gpt-6.1-terra`, `gpt-5.6-terra` ile birlikte kullanılabilir; varsayılan model ve alt ajan listesi değişmez. GitHub Copilot, Responses kullanır. OpenRouter kimliği `openai/gpt-6.1-terra`, bağlamı 1,050,000 ve milyon token başına giriş / çıkış fiyatı $1.00 / $5.00’dır. + +Yerel Codex: varsayılan bağlam 500,000, uzun bağlam tavanı 872,000, varsayılan effort `low`; düzeyler `low`, `medium`, `high`, `xhigh`, `max`, `ultra`. Yerel `ultra`, aktarımda `max` olarak gönderilir. Yerel satır yalnızca Fast sunar. + +[OpenAI API](https://developers.openai.com/api/docs/models/gpt-6.1-terra): bağlam 1,050,000, en fazla çıktı 128,000; metin/görsel girişi ve metin çıktısı; bilgi kesim tarihi 2026-06-01. Effort düzeyleri: `none`, `low`, `medium` (varsayılan), `high`, `xhigh`, `max`. Uç noktalar: `v1/responses` ve `v1/chat/completions`; araç çağrıları Responses gerektirir. + +Milyon token başına: giriş $1.00, önbellek okuma $0.05, önbellek yazma $1.25 (30 dakika), çıktı $5.00. Giriş 272,000 tokenı aşarsa tüm istekte giriş/önbellek 2×, çıktı 1.5× ücretlendirilir: sırasıyla $2.00 / $0.10 / $2.50 / $7.50. Fast modu 2× ücretlidir. + ## İlk kayıtta model seçimi Yeni OAuth dışı bağlantılar, modelleri göstermeden önce güvenilir bir model listesini bekler. Models sekmesinde en az 20 benzersiz model satırı varsa tüm model anahtarları başlangıçta OFF olur; sağlayıcının kendisi ACTIVE kalır. Gerçekte OAuth veya ChatGPT girişi kullanan bağlantılar varsayılanlarını korur. @@ -138,7 +148,7 @@ alanlı seçilmiş kimlikleri yalın kimliklere yeniden yazar. | `modelReasoningEfforts?` | `Record` | Model başına etiketler. Boş bir liste çaba denetimini gizler. `reasoningEfforts`'ta olduğu gibi, yapılandırılmış her `google` adaptör merdiveni `thinkingLevel` yeteneğini iddia eder; doğrudan ve Vertex görsel olmayan istekleri düz Gemini yolunu kullanırken, Cloud Code Assist bunu istek zarfı altında gönderir. | | `modelSupportsReasoningSummaries?` | `Record` | Özetlerin bildirilmesini durdurmak ve özet teslim alanlarını kaldırmak için bir modeli `false` olarak ayarlayın. | | `modelReasoningSummaryDelivery?` | `Record` | Model başına Responses teslim enum'ı; mevcut bir teslim alanını yeniden yazar. | -| `modelAdapters?` | `Record` | Karışık hatlı ağ geçitleri için model başına `openai-chat` veya `openai-responses` hat geçersiz kılma. Açık girdiler kayıt defteri varsayılanlarını yener. OpenCode Go önayarı, kardeş modelleri belgelenmiş hatlarında bırakırken `gpt-5.6-luna` için Responses'ı seçer; DeepSeek, `deepseek-v4-flash` için yerel Responses seçebilir; ve GitHub Copilot, modeller (`gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`) için yalnızca Responses varsayılanlarını bildirir çünkü bu modeller ajan trafiği için `/chat/completions`'ı reddeder. Yerleşik varsayılanı olmayan modeller (örneğin `gpt-5.4-nano`) burada dahil edilebilir. Tek hatlı yukarı akış pinleri ve kurallı ChatGPT iletme geçersiz kılmaları reddeder. | +| `modelAdapters?` | `Record` | Karışık hatlı ağ geçitleri için model başına `openai-chat` veya `openai-responses` hat geçersiz kılma. Açık girdiler kayıt defteri varsayılanlarını yener. OpenCode Go önayarı, kardeş modelleri belgelenmiş hatlarında bırakırken `gpt-5.6-luna` için Responses'ı seçer; DeepSeek, `deepseek-v4-flash` için yerel Responses seçebilir; ve GitHub Copilot, modeller (`gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-6.1-sol`, `gpt-6.1-terra`, `gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`) için yalnızca Responses varsayılanlarını bildirir çünkü bu modeller ajan trafiği için `/chat/completions`'ı reddeder. Yerleşik varsayılanı olmayan modeller (örneğin `gpt-5.4-nano`) burada dahil edilebilir. Tek hatlı yukarı akış pinleri ve kurallı ChatGPT iletme geçersiz kılmaları reddeder. | | xAI Responses katılımı (panel) | anahtar | Yalnızca `xai` için `grok-4.5` ve `grok-4.6` `modelAdapters` girdilerini atomik olarak ayarlar veya temizler. Tek girdi, sonraki anahtar yazımı ikisini eşitleyene kadar karma durum olarak görünür. Diğer geçersiz kılmalar ve katman davranışı değişmez. Grok 4.7, OAuth üzerinde kayıt defteri hat varsayılanıyla Responses kullanır ve açık `modelAdapters["grok-4.7"] = "openai-chat"` girdisiyle Chat’e geçirilebilir. | | `xaiResponsesXSearch?` | `boolean` | Varsayılan olarak devre dışıdır. Bir xAI Responses hedefinde, yalnızca canlı bir `web_search` aracı son istek normalleştirmesinden sağ çıktığında sağlayıcı tarafından barındırılan `x_search` bildirimini ekler. Mevcut bildirimler yinelenmez, çağıranın `tool_choice`/`allowed_tools` seçicileri hiçbir zaman genişletilmez ve bu, web araması yardımcı hizmetinin `search.xSearch` seçeneklerinden ayrıdır. | | `modelPreferHostedTools?` | `Record` | Barındırılan bir araç ad alanı ayıran iletme harici Responses ağ geçitleri için tam model dahil etme. Şu anda yalnızca `["image_generation"]` kabul eder; eşleşen bir model `openai-responses` hattını kullanmalı ve bu barındırılan aracı desteklemelidir. Çakışan istemci `image_gen` bildirimlerini kaldırır ve arayan araç seçimini korumak için seçicilerini yeniden yazar. OpenAI API sanal `-pro` modelleri için önce seçilen genel kimlik eşleştirilir ve çözümlenen temel hat model kimliği bir geri dönüştür. `modelAdapters` önce genel kimliği, ardından temel kimliği çözer; ikinci çözümleme son hattı belirler. Diğer modeller normal takma ad davranışını korur. | diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md b/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md index 5214e768367..3d2f9b0b3ff 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md @@ -65,10 +65,11 @@ visibility = "list" | 路由 | 选择器 id 与目录元数据 | | --- | --- | -| Codex 登录(账户限定的选择器行未启用) | 显示 `gpt-5.6-sol`、`gpt-5.6-terra`、`gpt-5.6-luna` 等裸原生 id,并按 `codexAccountMode` 使用 Pool 或 Direct。GPT-5.6 行使用 922,000-token 目录窗口。 | +| Codex 登录(账户限定的选择器行未启用) | 显示 `gpt-5.6-sol`、`gpt-5.6-terra`、`gpt-5.6-luna` 等裸原生 id,并按 `codexAccountMode` 使用 Pool 或 Direct。GPT-5.6 行使用 922,000-token 目录窗口。 `gpt-6.1-terra`:默认上下文 500,000 / 长上下文 872,000。 | | Codex 登录(账户限定的选择器行已启用且存在有效 selector) | 为每个有效 selector 与受支持原生模型的组合显示 `/` 行。每行只使用映射账户,裸原生行会从选择器中隐藏。原生 metadata 与 context window 会保留。 | -| OpenAI(API key) | 恰好八个命名空间行:`gpt-5.5`、`gpt-5.6`、Sol/Terra/Luna,以及三个 `*-pro` 虚拟 id(八个条目均为 1,050,000 context / 922,000 max input) | +| OpenAI(API key) | 命名空间模型包括 `gpt-5.5`、`gpt-5.6`、Sol/Terra/Luna、`*-pro` 虚拟 id 和 Daybreak 别名(上下文 1,050,000;最大输入 922,000)。`openai-apikey/gpt-6.1-terra` 的上下文为 1,050,000,最大输出为 128,000。 | | OpenRouter | `openrouter/openai/gpt-5.6-sol`、`openrouter/openai/gpt-5.6-terra`、`openrouter/openai/gpt-5.6-luna`(922,000) | +| OpenRouter | `openrouter/openai/gpt-6.1-terra` (1,050,000) | | Cursor | 静态回退包含 `cursor/gpt-5.6-sol`、`cursor/gpt-5.6-terra`、`cursor/gpt-5.6-luna`(1,000,000),以及 Grok 4.5/4.6/4.7 的普通和 Fast 条目(500,000)。4.6 和 4.7 还提供 `xhigh`;实时账户发现会决定最终哪些条目仍然可见。 | | xAI | 实时发现具有权威性。回退目录包含 `xai/grok-4.6` 和 `xai/grok-4.7`,默认模型仍为 `xai/grok-4.5`;三者的上下文窗口均为 500,000。Grok 4.6 和 4.7 提供 `low` / `medium` / `high` / `xhigh`(上游默认值为 `high`),Grok 4.5 最高为 `high`。 | diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index cdf84045d8a..55dbf90e35e 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -503,6 +503,9 @@ GPT-5.6 Sol/Terra/Luna 会预置在提供商的回退列表中,因此即使实 | Codex 登录(Pool 或 Direct) | `gpt-5.6-*` | 922,000 | | OpenAI (API key) | `openai-apikey/gpt-5.6-*` 和 `*-pro` | 922,000(max input 922,000) | | OpenRouter | `openrouter/openai/gpt-5.6-sol`、`openrouter/openai/gpt-5.6-terra`、`openrouter/openai/gpt-5.6-luna` | 922,000 | +| Codex | `gpt-6.1-terra` | 500,000 / 872,000 | +| OpenAI API | `openai-apikey/gpt-6.1-terra` | 1,050,000 (128,000 max output) | +| OpenRouter | `openrouter/openai/gpt-6.1-terra` | 1,050,000 | | Cursor | `cursor/gpt-5.6-sol`、`cursor/gpt-5.6-terra`、`cursor/gpt-5.6-luna` | 1,000,000 | 原生 GPT-5.6 条目保留固定的上游 reasoning 档位,例如 Luna 有 `max`,但没有 `ultra`。路由条目 @@ -535,7 +538,7 @@ GitHub 可能改变协议或账户权限。Auto 每次请求使用选中模型 不是已公布的 GitHub 限制;过大的目录、缓慢的协商或意图文本截断都可能影响兼容性。 具名选择时,Copilot 提供混合 wire 目录:其模型(`gpt-5.3-codex`、`gpt-5.4`、 -`gpt-5.4-mini`、`gpt-5.5`、`gpt-5.6-luna`、`gpt-5.6-sol`、`gpt-5.6-terra`、`gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`)会拒绝面向 +`gpt-5.4-mini`、`gpt-5.5`、`gpt-5.6-luna`、`gpt-5.6-sol`、`gpt-5.6-terra`、 `gpt-6.1-sol`、 `gpt-6.1-terra`、 `gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`)会拒绝面向 agent 流量的 `/chat/completions`,因此 opencodex 默认将这些模型路由到 Responses API,而其他 Copilot 模型仍走 chat completions。优先级为:硬 wire 固定 → 显式 [`modelAdapters`](/zh-cn/reference/configuration/providers/) 条目 → 注册表默认值 → 提供商级 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 6a26f9cab26..bbe40d89bbc 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 @@ -11,6 +11,16 @@ description: 提供者条目、身份验证、端点、模型目录、配额、 该推断默认关闭,需要按渠道设置 `inferContextWindow: true` 开启。未开启时,缺少窗口的模型使用已启用的 Context cap,否则回退到 128k。 +## GPT-6.1 Terra + +[GPT-6.1 Terra](https://openai.com/index/introducing-gpt-6-1-terra/) 于 2026年10月26日发布,是 GPT-5.6 Terra 的后继型号。`gpt-6.1-terra` 与 `gpt-5.6-terra` 并列提供,不修改默认模型或子代理列表。GitHub Copilot 使用 Responses;OpenRouter ID 为 `openai/gpt-6.1-terra`,上下文为 1,050,000,每百万 token 输入 $1.00、输出 $5.00。 + +原生 Codex:默认上下文 500,000,长上下文上限 872,000,默认 effort 为 `low`,级别为 `low`、`medium`、`high`、`xhigh`、`max`、`ultra`。原生 `ultra` 在传输时映射为 `max`。原生条目仅提供 Fast 高速模式。 + +[OpenAI API](https://developers.openai.com/api/docs/models/gpt-6.1-terra):上下文 1,050,000,最大输出 128,000;支持文本和图像输入、文本输出;知识截止日期为 2026-06-01。effort 为 `none`、`low`、`medium`(默认)、`high`、`xhigh`、`max`。端点为 `v1/responses` 和 `v1/chat/completions`;工具调用必须使用 Responses。 + +每百万 token:输入 $1.00、缓存输入 $0.05、缓存写入 $1.25(30分钟)、输出 $5.00。输入超过 272,000 token 时,整个请求按输入和缓存 2×、输出 1.5×计费,依次为 $2.00 / $0.10 / $2.50 / $7.50。Fast 模式价格为 2×。 + ## 首次注册时的模型选择 新的非 OAuth 连接会等待可靠的模型列表,再公开模型。如果 Models 标签页中去重后的模型行达到20个,所有模型开关初始为 OFF,但提供者本身保持 ACTIVE。实际认证方式为 OAuth 或 ChatGPT 登录的连接保留默认设置。 @@ -128,7 +138,7 @@ selector,而不是分配一个新名称。 | `modelReasoningEfforts?` | `Record` | 按模型设置的标签。空列表会隐藏 effort 控件。 | | `modelSupportsReasoningSummaries?` | `Record` | 将某个模型设为 `false`,即可停止暴露摘要并移除摘要交付字段。 | | `modelReasoningSummaryDelivery?` | `Record` | 按模型设置的 Responses 交付枚举;会重写现有的 delivery 字段。 | -| `modelAdapters?` | `Record` | 按模型设置的 `openai-chat` 或 `openai-responses` 线协议覆盖项,用于混合线协议网关。显式条目优先于注册表默认值;DeepSeek 预设可以为 `deepseek-v4-flash` 选择原生 Responses,GitHub Copilot 则为 模型(`gpt-5.3-codex`、`gpt-5.4`、`gpt-5.4-mini`、`gpt-5.5`、`gpt-5.6-luna`、`gpt-5.6-sol`、`gpt-5.6-terra`、`gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`)声明了 Responses 专用默认值,因为这些模型在代理流量下会拒绝 `/chat/completions`。没有内置默认值的模型(例如 `gpt-5.4-nano`)可以在此手动启用。单一线协议上游固定项和规范 ChatGPT forward 会拒绝覆盖。 | +| `modelAdapters?` | `Record` | 按模型设置的 `openai-chat` 或 `openai-responses` 线协议覆盖项,用于混合线协议网关。显式条目优先于注册表默认值;DeepSeek 预设可以为 `deepseek-v4-flash` 选择原生 Responses,GitHub Copilot 则为 模型(`gpt-5.3-codex`、`gpt-5.4`、`gpt-5.4-mini`、`gpt-5.5`、`gpt-5.6-luna`、`gpt-5.6-sol`、`gpt-5.6-terra`, `gpt-6.1-sol`, `gpt-6.1-terra`、`gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`)声明了 Responses 专用默认值,因为这些模型在代理流量下会拒绝 `/chat/completions`。没有内置默认值的模型(例如 `gpt-5.4-nano`)可以在此手动启用。单一线协议上游固定项和规范 ChatGPT forward 会拒绝覆盖。 | | xAI Responses 启用项(仪表板) | 开关 | 仅用于 `xai`,以原子方式设置或清除 `grok-4.5` 和 `grok-4.6` 的 `modelAdapters` 条目。若只存在一个条目,则显示混合状态,直到下次开关写入将两者统一。其他覆盖项和层级行为不变。 Grok 4.7 在 OAuth 上通过注册表线协议默认使用 Responses,也可通过显式的 `modelAdapters["grok-4.7"] = "openai-chat"` 条目切换到 Chat。 | | `xaiResponsesXSearch?` | `boolean` | 默认禁用。在 xAI Responses 目标上,仅当有效的 `web_search` 工具在最终请求规范化后仍保留时,才附加由提供方托管的 `x_search` 声明。不会重复已有声明,绝不会扩大调用方的 `tool_choice`/`allowed_tools` 选择范围,并且此项独立于网络搜索辅助服务的 `search.xSearch` 选项。 | | `modelPreferHostedTools?` | `Record` | 非 forward Responses gateway 的精确模型 ID opt-in,用于上游预留 hosted tool namespace 的情况。目前只支持 `["image_generation"]`;匹配模型必须使用 `openai-responses` wire 且支持该 hosted 工具。它会移除冲突的客户端 `image_gen` 声明,并改写其 selector 以保持调用方的 tool choice。对于 OpenAI API 的虚拟 `-pro` 模型,先匹配所选公开 ID,未命中时才使用解析出的基础 wire-model ID 作为回退。`modelAdapters` 会先按公开 ID、再按基础 ID 解析;后一次结果决定最终 wire。未配置模型保持普通 alias 行为。 | diff --git a/docs-site/src/content/docs/zh-tw/guides/codex-app-models.md b/docs-site/src/content/docs/zh-tw/guides/codex-app-models.md index 176a68a8bfd..2b9eb7c20aa 100644 --- a/docs-site/src/content/docs/zh-tw/guides/codex-app-models.md +++ b/docs-site/src/content/docs/zh-tw/guides/codex-app-models.md @@ -86,10 +86,11 @@ GPT-5.6,以便提供每個模型真實的身份和後設資料,而不是套 | 路由 | 選擇器 id 與目錄後設資料 | | --- | --- | -| Codex 登入(停用帳號限定列) | 裸原生 id,例如 `gpt-5.6-sol`、`gpt-5.6-terra`、`gpt-5.6-luna`;透過 `codexAccountMode` 選擇 Pool 或 Direct。GPT-5.6 列使用 922,000 token 的目錄視窗。 | +| Codex 登入(停用帳號限定列) | 裸原生 id,例如 `gpt-5.6-sol`、`gpt-5.6-terra`、`gpt-5.6-luna`;透過 `codexAccountMode` 選擇 Pool 或 Direct。GPT-5.6 列使用 922,000 token 的目錄視窗。 `gpt-6.1-terra`:預設上下文 500,000 / 長上下文 872,000。 | | Codex 登入(啟用帳號限定列且有合格選擇器) | 每個合格選擇器與受支援的原生模型各有一列 `/`;每列只使用其對應帳號,且裸原生列會從選擇器中隱藏。原生後設資料與 context 視窗保持不變。 | -| OpenAI(API key) | 恰好八個帶名稱空間的列:`gpt-5.5`、`gpt-5.6`、Sol/Terra/Luna 與三個 `*-pro` 虛擬 id(全部八個都是 1,050,000 context;922,000 max input) | +| OpenAI(API key) | 帶命名空間的模型包括 `gpt-5.5`、`gpt-5.6`、Sol/Terra/Luna、`*-pro` 虛擬 id 與 Daybreak 別名(上下文 1,050,000;最大輸入 922,000)。`openai-apikey/gpt-6.1-terra` 的上下文為 1,050,000,最大輸出為 128,000。 | | OpenRouter | `openrouter/openai/gpt-5.6-sol`、`openrouter/openai/gpt-5.6-terra`、`openrouter/openai/gpt-5.6-luna`(922,000) | +| OpenRouter | `openrouter/openai/gpt-6.1-terra` (1,050,000) | | Cursor | 靜態回退目錄包含 `cursor/gpt-5.6-sol`、`cursor/gpt-5.6-terra`、`cursor/gpt-5.6-luna`(1,000,000),以及 Grok 4.5/4.6/4.7 的一般與 Fast 項目(500,000)。4.6 和 4.7 還提供 `xhigh`;帳號的即時發現結果決定最終顯示哪些模型。 | | xAI | 以即時發現結果為準。回退目錄包含 `xai/grok-4.6` 與 `xai/grok-4.7`,預設模型仍為 `xai/grok-4.5`;三者的 context window 均為 500,000。Grok 4.6 與 4.7 提供 `low` / `medium` / `high` / `xhigh`(上游預設值為 `high`),Grok 4.5 最高為 `high`。 | diff --git a/docs-site/src/content/docs/zh-tw/guides/providers.md b/docs-site/src/content/docs/zh-tw/guides/providers.md index 460b9b85b70..9683b3925be 100644 --- a/docs-site/src/content/docs/zh-tw/guides/providers.md +++ b/docs-site/src/content/docs/zh-tw/guides/providers.md @@ -533,6 +533,9 @@ GPT-5.6 Sol/Terra/Luna 會預置在 provider fallback list 中,因此即使即 | Codex 登入(Pool 或 Direct) | `gpt-5.6-*` | 922,000 | | OpenAI (API key) | `openai-apikey/gpt-5.6-*` 加 `*-pro` | 922,000(922,000 max input) | | OpenRouter | `openrouter/openai/gpt-5.6-sol`、`openrouter/openai/gpt-5.6-terra`、`openrouter/openai/gpt-5.6-luna` | 922,000 | +| Codex | `gpt-6.1-terra` | 500,000 / 872,000 | +| OpenAI API | `openai-apikey/gpt-6.1-terra` | 1,050,000 (128,000 max output) | +| OpenRouter | `openrouter/openai/gpt-6.1-terra` | 1,050,000 | | Cursor | `cursor/gpt-5.6-sol`、`cursor/gpt-5.6-terra`、`cursor/gpt-5.6-luna` | 1,000,000 | 原生 GPT-5.6 條目保留固定的上游 reasoning ladder,例如 Luna 有 `max` 但沒有 `ultra`。路由條目使用各 @@ -550,7 +553,7 @@ Antigravity/Cloud Code Assist 模式)、`azure` / `azure-openai`、`kiro`、 key/subscription-token gateway。**Cloudflare AI Gateway** 需要在 URL 填入 account 與 gateway id。 Copilot 的 catalog 混合多種 wire:模型(`gpt-5.3-codex`、`gpt-5.4`、`gpt-5.4-mini`、 -`gpt-5.5`、`gpt-5.6-luna`、`gpt-5.6-sol`、`gpt-5.6-terra`、`gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`)會拒絕 agent traffic 的 +`gpt-5.5`、`gpt-5.6-luna`、`gpt-5.6-sol`、`gpt-5.6-terra`、 `gpt-6.1-sol`、 `gpt-6.1-terra`、 `gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`)會拒絕 agent traffic 的 `/chat/completions`,因此 opencodex 會依內建預設把這些模型路由到 Responses API;其他 Copilot 模型 仍使用 chat completions。優先順序為:hard wire pin → 你明確設定的 [`modelAdapters`](/zh-tw/reference/configuration/providers/) → registry default → provider-wide adapter。 diff --git a/docs-site/src/content/docs/zh-tw/guides/sub-agent-surface.md b/docs-site/src/content/docs/zh-tw/guides/sub-agent-surface.md index a27641478fd..6f9d6cdf2f8 100644 --- a/docs-site/src/content/docs/zh-tw/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/zh-tw/guides/sub-agent-surface.md @@ -20,7 +20,7 @@ opencodex 允許你為目錄中的所有模型選擇多代理協作介面。儀 | 模式 | 介面 | 行為 | | --- | --- | --- | | **v1** | `multi_agent_v1` | 使用經典的名稱空間代理工具,以及 `send_input` / `close_agent` / `resume_agent`。`spawn_agent` 的模型覆蓋可以在其他模型上生成子代理。 | -| **base**(預設) | 上游固定值 | 恢復上游模型的固定值:gpt-5.6-sol 和 gpt-5.6-terra 使用 v2,gpt-5.6-luna 使用 v1;未固定的模型遵循 Codex 的 `multi_agent_v2` 功能開關。生成行為取決於該模型最終使用的介面。 | +| **base**(預設) | 上游固定值 | 恢復上游模型的固定值:gpt-5.6-sol、gpt-5.6-terra 和 gpt-6.1-terra 使用 v2,gpt-5.6-luna 使用 v1;未固定的模型遵循 Codex 的 `multi_agent_v2` 功能開關。生成行為取決於該模型最終使用的介面。 | | **v2** | `multi_agent_v2` | 使用扁平的 `spawn_agent` 工具、併發會話,以及 `send_message` / `followup_task` / `wait_agent` / `interrupt_agent`。全量歷史 fork 時子代理繼承父模型;`fork_turns: "none"`(或部分 fork)時接受 `model` / `reasoning_effort` 覆蓋。如果原生→路由子代理只收到後端加密的任務內容,未明確信任的外部路由會回傳 `unreadable_encrypted_agent_task`;明確信任的直接金鑰驗證 Responses 路由可以原樣接收,而混合組合仍優先選擇可解密的原生目標([#92](https://github.com/lidge-jun/opencodex/issues/92))。 | ## 運作原理 @@ -256,7 +256,7 @@ opencodex 會將磁碟上的模型目錄與目前使用者擁有的每個 Codex | 模型 | 線路上的 `max` | 選擇 `ultra` 後的線路值 | | --- | --- | --- | | gpt-5.5 | xhigh | xhigh(先轉為 max,再經 `nativeEffortClamp`) | -| gpt-5.6-sol、gpt-5.6-terra | max | max | +| gpt-5.6-sol、gpt-5.6-terra、gpt-6.1-terra | max | max | | gpt-5.6-luna | max | 其精確上游階梯不提供該選項 | | 路由模型 | 由適配器對映或限制 | 先轉為 max,再由適配器對映或限制 | 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 183cdf3a409..042eeefef15 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 @@ -11,6 +11,16 @@ description: 供應商項目、認證、端點、模型目錄、配額、context 此推斷預設關閉,需按供應商設定 `inferContextWindow: true` 開啟。未開啟時,缺少視窗的模型使用已啟用的 Context cap,否則回退至 128k。 +## GPT-6.1 Terra + +[GPT-6.1 Terra](https://openai.com/index/introducing-gpt-6-1-terra/) 於 2026年10月26日發布,是 GPT-5.6 Terra 的後繼型號。`gpt-6.1-terra` 與 `gpt-5.6-terra` 並列提供,不修改預設模型或子代理清單。GitHub Copilot 使用 Responses;OpenRouter ID 為 `openai/gpt-6.1-terra`,上下文為 1,050,000,每百萬 token 輸入 $1.00、輸出 $5.00。 + +原生 Codex:預設上下文 500,000,長上下文上限 872,000,預設 effort 為 `low`,級別為 `low`、`medium`、`high`、`xhigh`、`max`、`ultra`。原生 `ultra` 在傳輸時對應至 `max`。原生項目僅提供 Fast 高速模式。 + +[OpenAI API](https://developers.openai.com/api/docs/models/gpt-6.1-terra):上下文 1,050,000,最大輸出 128,000;支援文字與影像輸入、文字輸出;知識截止日期為 2026-06-01。effort 為 `none`、`low`、`medium`(預設)、`high`、`xhigh`、`max`。端點為 `v1/responses` 和 `v1/chat/completions`;工具呼叫必須使用 Responses。 + +每百萬 token:輸入 $1.00、快取輸入 $0.05、快取寫入 $1.25(30分鐘)、輸出 $5.00。輸入超過 272,000 token 時,整個請求按輸入與快取 2×、輸出 1.5×計費,依序為 $2.00 / $0.10 / $2.50 / $7.50。Fast 模式價格為 2×。 + ## 首次註冊時的模型選擇 新的非 OAuth 連線會先等待可靠的模型清單,再公開模型。如果 Models 分頁中去重後的模型列達到20個,所有模型開關初始為 OFF,但供應商本身保持 ACTIVE。實際驗證方式為 OAuth 或 ChatGPT 登入的連線保留預設值。 @@ -103,7 +113,7 @@ ocx models provider openrouter on | `modelReasoningEfforts?` | `Record` | Per-model 標籤。空清單隱藏 effort 控制。 | | `modelSupportsReasoningSummaries?` | `Record` | 將模型設為 `false` 以停止廣告摘要並剝離 summary-delivery 欄位。 | | `modelReasoningSummaryDelivery?` | `Record` | Per-model Responses delivery 列舉;重寫既有的 delivery 欄位。 | -| `modelAdapters?` | `Record` | 混合 wire 閘道的 Per-model `openai-chat` 或 `openai-responses` wire 覆寫。明確項目勝過 registry 預設;DeepSeek 的預設可為 `deepseek-v4-flash` 選擇原生 Responses。單一 wire 上游 pin 與規範 ChatGPT forward 拒絕覆寫。 | +| `modelAdapters?` | `Record` | 混合 wire 閘道的 Per-model `openai-chat` 或 `openai-responses` wire 覆寫。明確項目勝過 registry 預設;DeepSeek 的預設可為 `deepseek-v4-flash` 選擇原生 Responses。單一 wire 上游 pin 與規範 ChatGPT forward 拒絕覆寫。 GitHub Copilot 為 `gpt-6.1-sol` 與 `gpt-6.1-terra` 使用 Responses。 | | xAI Responses 選用(儀表板) | 開關 | 僅用於 `xai`,以原子方式設定或清除 `grok-4.5` 與 `grok-4.6` 的 `modelAdapters` 項目。若只有一個項目,會顯示混合狀態,直到下次開關寫入統一兩者。其他覆寫與層級行為不變。 Grok 4.7 在 OAuth 上透過登錄檔 wire 預設使用 Responses,也可透過明確的 `modelAdapters["grok-4.7"] = "openai-chat"` 項目切換至 Chat。 | | `annotateEmptyToolOutputs?` | `boolean` | 在工具結果送達模型前,將已存在但為空的結果替換成簡短標記,使空白結果不會被解讀為遺漏的結果。適用於空白字串及僅含文字部分的陣列;影像、檔案及加密部分絕不會被更動。DeepSeek 透過內建登錄檔預設為 `true`,其他情況則不設定。設為 `false` 可讓供應商停用此功能;後續編輯即使省略此欄位,也會保留明確設定的 `false`。`PATCH /api/providers?name=` 接受 `true`、`false` 或 `null`;`null` 會清除覆寫並恢復使用登錄檔的預設行為。 | | `xaiResponsesXSearch?` | `boolean` | 預設停用。在 xAI Responses 目的地上,僅當即時 `web_search` 工具通過最終請求正規化後仍保留時,才附加由供應商託管的 `x_search` 宣告。既有宣告不會重複,呼叫端的 `tool_choice`/`allowed_tools` 選擇器絕不會擴大,且此設定與網頁搜尋輔助服務的 `search.xSearch` 選項分開。 | diff --git a/scripts/model-metadata.source.json b/scripts/model-metadata.source.json index 9e6b6adc015..931f97ef936 100644 --- a/scripts/model-metadata.source.json +++ b/scripts/model-metadata.source.json @@ -2910,6 +2910,32 @@ "maxLevel": "max" } }, + "openai.gpt-6.1-terra": { + "id": "openai.gpt-6.1-terra", + "name": "GPT-6.1 Terra", + "api": "bedrock-converse-stream", + "provider": "amazon-bedrock", + "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 1, + "output": 5, + "cacheRead": 0.05, + "cacheWrite": 1.25 + }, + "contextWindow": 373000, + "maxTokens": 128000, + "thinking": { + "mode": "budget", + "minLevel": "low", + "maxLevel": "max" + }, + "_comment": "261027 preemptive: GPT-6.1 Terra (provider limits mirror GPT-6.1 Sol; OpenAI API reference prices)." + }, "openai.gpt-5.6-terra": { "id": "openai.gpt-5.6-terra", "name": "GPT-5.6 Terra", @@ -11713,6 +11739,35 @@ "maxLevel": "max" } }, + "gpt-6.1-terra": { + "id": "gpt-6.1-terra", + "name": "GPT-6.1 Terra", + "api": "openai-responses", + "provider": "github-copilot", + "baseUrl": "https://api.githubcopilot.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 1, + "output": 5, + "cacheRead": 0.05, + "cacheWrite": 1.25 + }, + "contextWindow": 373000, + "maxTokens": 128000, + "headers": { + "User-Agent": "opencode/1.3.15" + }, + "thinking": { + "mode": "effort", + "minLevel": "low", + "maxLevel": "max" + }, + "_comment": "261027 preemptive: GPT-6.1 Terra (provider limits mirror GPT-6.1 Sol; OpenAI API reference prices)." + }, "gpt-5.6-terra": { "id": "gpt-5.6-terra", "name": "GPT-5.6 Terra", @@ -60582,6 +60637,32 @@ "maxLevel": "max" } }, + "gpt-6.1-terra": { + "id": "gpt-6.1-terra", + "name": "GPT-6.1 Terra", + "cost": { + "input": 1, + "output": 5, + "cacheRead": 0.05, + "cacheWrite": 1.25 + }, + "api": "openai-responses", + "provider": "openai", + "baseUrl": "", + "reasoning": true, + "input": [ + "text", + "image" + ], + "contextWindow": 1050000, + "maxTokens": 128000, + "applyPatchToolType": "freeform", + "thinking": { + "mode": "effort", + "minLevel": "none", + "maxLevel": "max" + } + }, "gpt-5.6-terra": { "id": "gpt-5.6-terra", "name": "GPT-5.6 Terra", @@ -61444,6 +61525,33 @@ "maxLevel": "max" } }, + "gpt-6.1-terra": { + "id": "gpt-6.1-terra", + "name": "GPT-6.1 Terra", + "cost": { + "input": 1, + "output": 5, + "cacheRead": 0.05, + "cacheWrite": 1.25 + }, + "api": "openai-codex-responses", + "provider": "openai-codex", + "baseUrl": "https://chatgpt.com/backend-api", + "reasoning": true, + "input": [ + "text", + "image" + ], + "contextWindow": 500000, + "maxTokens": 128000, + "preferWebsockets": true, + "applyPatchToolType": "freeform", + "thinking": { + "mode": "effort", + "minLevel": "low", + "maxLevel": "max" + } + }, "gpt-5.6-terra": { "id": "gpt-5.6-terra", "name": "GPT-5.6 Terra", @@ -70116,6 +70224,31 @@ "maxLevel": "max" } }, + "openai/gpt-6.1-terra": { + "id": "openai/gpt-6.1-terra", + "name": "GPT-6.1 Terra", + "api": "openai-completions", + "provider": "openrouter", + "baseUrl": "https://openrouter.ai/api/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 1, + "output": 5, + "cacheRead": 0.05, + "cacheWrite": 1.25 + }, + "contextWindow": 1050000, + "maxTokens": 128000, + "thinking": { + "mode": "effort", + "minLevel": "low", + "maxLevel": "max" + } + }, "openai/gpt-5.6-sol-pro": { "id": "openai/gpt-5.6-sol-pro", "name": "OpenAI: GPT-5.6 Sol Pro", @@ -81632,6 +81765,32 @@ "maxLevel": "max" } }, + "openai/gpt-6.1-terra": { + "id": "openai/gpt-6.1-terra", + "name": "GPT-6.1 Terra", + "api": "anthropic-messages", + "provider": "vercel-ai-gateway", + "baseUrl": "https://ai-gateway.vercel.sh", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 1, + "output": 5, + "cacheRead": 0.05, + "cacheWrite": 1.25 + }, + "contextWindow": 373000, + "maxTokens": 128000, + "thinking": { + "mode": "budget", + "minLevel": "low", + "maxLevel": "max" + }, + "_comment": "261027 preemptive: GPT-6.1 Terra (provider limits mirror GPT-6.1 Sol; OpenAI API reference prices)." + }, "openai/gpt-5.6-terra": { "id": "openai/gpt-5.6-terra", "name": "GPT-5.6 Terra", diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index e4b15eb7c8e..ba88b63756d 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -911,7 +911,7 @@ "devin-reasoning-continuation.test.ts": "providers", "devin-stream-deadline.test.ts": "providers", "digitalocean-scaleway-provider.test.ts": "providers", - "docs-429-failover-claims.test.ts": "ci-workflows", + "docs-429-failover-claims.test.ts": "ci-workflows", "docs-gpt61-terra.test.ts": "ci-workflows", "docs-bun-source-requirement.test.ts": "ci-workflows", "docs-developer-role-policy.test.ts": "ci-workflows", "docs-gui-screenshot-policy.test.ts": "ci-workflows", @@ -1011,7 +1011,7 @@ "google-wire-compiler.test.ts": "adapters/google", "google-wire-shape.test.ts": "adapters/google", "gpt6-native-rows.test.ts": "codex-integration", - "gpt61-sol-rows.test.ts": "codex-integration", + "gpt61-sol-rows.test.ts": "codex-integration", "gpt61-terra-rows.test.ts": "codex-integration", "grok-attribution.test.ts": "providers/xai", "grok-config-inject.test.ts": "providers/xai", "grok-config-write-lock.test.ts": "providers/xai", "grok-effort-inject.test.ts": "providers/xai", @@ -1367,7 +1367,7 @@ "openrouter-provider-routing.test.ts": "providers", "openrouter-quota-reset-cooldown-4024.test.ts": "providers", "opper-provider.test.ts": "providers", "modelflare-provider.test.ts": "providers", "aistupidlevel-provider.test.ts": "providers", - "tokenlab-protocols.test.ts": "providers", + "tokenlab-protocols.test.ts": "providers", "gpt61-terra-provider-rows.test.ts": "providers", "tokenlab-provider.test.ts": "providers", "optional-shutdown-hooks.test.ts": "lib", "orca-import.test.ts": "codex-integration", @@ -1866,7 +1866,7 @@ "usage-anthropic-fast-pricing.test.ts": "usage", "usage-cursor-fast-pricing.test.ts": "usage", "usage-attempt-delivery.test.ts": "usage", - "usage-cost.test.ts": "usage", "usage-haiku-5-5-pricing.test.ts": "usage", + "usage-cost.test.ts": "usage", "usage-haiku-5-5-pricing.test.ts": "usage", "gpt61-terra-pricing.test.ts": "usage", "usage-debug.test.ts": "usage", "usage-failure-fingerprint.test.ts": "usage", "usage-failure-persistence.test.ts": "usage", diff --git a/src/adapters/devin/live-models.ts b/src/adapters/devin/live-models.ts index 9295b58026c..a9893e5b35d 100644 --- a/src/adapters/devin/live-models.ts +++ b/src/adapters/devin/live-models.ts @@ -27,6 +27,8 @@ export const DEVIN_STATIC_MODELS = [ // 260930 preemptive: GPT-6.1 Sol (devin.ai/blog/gpt-6-1-sol says it is live; the uid is not published). // Spelled the way Devin spells gpt-5.6-sol; live discovery replaces this seed once a credential is present. "gpt-6-1-sol", + // 261027 preemptive: GPT-6.1 Terra (mirrors Devin's GPT-6.1 Sol seed; uid unverified). + "gpt-6-1-terra", "claude-opus-4-8", "claude-fable-5-1", "claude-sonnet-5", @@ -66,6 +68,8 @@ export const DEVIN_MODEL_CONTEXT_WINDOWS: Record = { "gpt-6-luna": 1_000_000, // 260930 preemptive: unmeasured; mirrors the GPT-6 Sol row Cognition serves. "gpt-6-1-sol": 1_000_000, + // 261027 preemptive: GPT-6.1 Terra (unmeasured; mirrors Devin's GPT-6.1 Sol window). + "gpt-6-1-terra": 1_000_000, "claude-opus-4-8": 1_000_000, // 260923: read from the live catalog (devin/claude-opus-5-5 context_length 1_000_000). "claude-opus-5-5": 1_000_000, diff --git a/src/adapters/kiro/reasoning.ts b/src/adapters/kiro/reasoning.ts index 55a9c1ca192..c8f865b8d54 100644 --- a/src/adapters/kiro/reasoning.ts +++ b/src/adapters/kiro/reasoning.ts @@ -28,6 +28,8 @@ export const KIRO_NATIVE_EFFORT_FIELDS: Record= 0.153.0 upstream; older installed catalogs lack it. NATIVE_GPT61_SOL_MODEL, + // Terra's pinned row has the same client floor and backfills older installed catalogs too. + NATIVE_GPT61_TERRA_MODEL, ]; export function configuredNativeAliasSlugs( @@ -207,6 +212,7 @@ export const NATIVE_OPENAI_CONTEXT_OVERRIDES: Record