diff --git a/scripts/import-cycles.ts b/scripts/import-cycles.ts new file mode 100644 index 00000000000..ee0cd144d5a --- /dev/null +++ b/scripts/import-cycles.ts @@ -0,0 +1,178 @@ +import { readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join, relative, resolve, sep } from "node:path"; + +/** + * Import-cycle ratchet for the proxy runtime (issue #6386, INV-LAYER-01). + * + * The runtime import graph once held one strongly connected component of 222 modules, so an edit + * in nearly any domain could reach nearly any other, and the three files INV-LAB-01 protects sat + * inside it. Breaking that component is incremental work. This scanner keeps the progress: the + * baseline lists every module that is still on a cycle, and the test beside it fails when a module + * outside that list joins one, or when a listed module leaves without the list shrinking with it. + * + * Edges are load-time value imports parsed by Bun: static imports, side-effect imports, + * and runtime re-exports. Resolution follows Bun's local module precedence. + * \`import type\` is erased and a dynamic \`import()\` is a deferred edge that only loads when its + * branch runs, which is the sanctioned way to break a cycle, so neither forms an edge here. + * + * Importing this module scans nothing. \`bun scripts/import-cycles.ts\` prints the current count and + * \`--update\` rewrites the baseline, which only ever removes members. + */ + +export const BASELINE_REL = "tests/fixtures/import-cycle-baseline.json"; +export const SOURCE_ROOT = "src"; + +const importScanner = new Bun.Transpiler({ loader: "ts" }); +const tsxImportScanner = new Bun.Transpiler({ loader: "tsx" }); + +export type ImportGraph = Map; + +export interface CycleBaseline { + /** Modules (repo-relative, forward slashes) that sit on a static value-import cycle. */ + cyclic: string[]; +} + +export interface CycleVerdict { + /** Modules on a cycle that the baseline does not list: a regression. */ + added: string[]; + /** Listed modules that are no longer on a cycle: the baseline must shrink. */ + resolved: string[]; +} + +function toRepoPath(root: string, abs: string): string { + return relative(root, abs).split(sep).join("/"); +} + +function listSources(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const abs = join(dir, entry.name); + if (entry.isDirectory()) out.push(...listSources(abs)); + else if (/\.(ts|tsx|mts)$/.test(entry.name) && !/\.d\.(ts|mts)$/.test(entry.name)) out.push(abs); + } + return out; +} + +function resolveSpecifier(spec: string, fromFile: string): string | null { + if (!spec.startsWith(".")) return null; + try { + const candidate = Bun.resolveSync(spec, dirname(fromFile)); + return /\.(ts|tsx|mts)$/.test(candidate) ? candidate : null; + } catch { + // Unresolved edges and non-TypeScript modules are outside this source graph. + return null; + } +} + +/** Static value-import edges between source files in \`importer -> imported\` form. */ +export function staticImports(source: string, tsx = false): string[] { + // scanImports does not accept executable entrypoint hashbangs; they are not module syntax. + const moduleSource = source.replace(/^#![^\r\n]*/, ""); + return (tsx ? tsxImportScanner : importScanner).scanImports(moduleSource) + .filter(edge => edge.kind === "import-statement") + .map(edge => edge.path); +} + +export function scanGraph(root: string, sourceRoot = SOURCE_ROOT): ImportGraph { + const graph: ImportGraph = new Map(); + for (const abs of listSources(join(root, sourceRoot)).sort()) { + const targets = new Set(); + for (const spec of staticImports(readFileSync(abs, "utf8"), abs.endsWith(".tsx"))) { + const hit = resolveSpecifier(spec, abs); + if (hit) targets.add(toRepoPath(root, hit)); + } + graph.set(toRepoPath(root, abs), [...targets].sort()); + } + return graph; +} + +/** Strongly connected components with more than one member, or a module importing itself. */ +export function cyclicComponents(graph: ImportGraph): string[][] { + const index = new Map(); + const low = new Map(); + const onStack = new Set(); + const stack: string[] = []; + const components: string[][] = []; + let next = 0; + for (const start of graph.keys()) { + if (index.has(start)) continue; + // Iterative Tarjan: a recursive walk over a deep chain would exhaust the call stack. + const frames: { node: string; edge: number }[] = [{ node: start, edge: 0 }]; + index.set(start, next); + low.set(start, next++); + stack.push(start); + onStack.add(start); + while (frames.length > 0) { + const frame = frames[frames.length - 1]!; + const edges = graph.get(frame.node) ?? []; + if (frame.edge < edges.length) { + const target = edges[frame.edge++]!; + if (!graph.has(target)) continue; + if (!index.has(target)) { + index.set(target, next); + low.set(target, next++); + stack.push(target); + onStack.add(target); + frames.push({ node: target, edge: 0 }); + } else if (onStack.has(target)) { + low.set(frame.node, Math.min(low.get(frame.node)!, index.get(target)!)); + } + continue; + } + frames.pop(); + const parent = frames[frames.length - 1]; + if (parent) low.set(parent.node, Math.min(low.get(parent.node)!, low.get(frame.node)!)); + if (low.get(frame.node) !== index.get(frame.node)) continue; + const component: string[] = []; + let member: string; + do { + member = stack.pop()!; + onStack.delete(member); + component.push(member); + } while (member !== frame.node); + const selfLoop = component.length === 1 && (graph.get(member) ?? []).includes(member); + if (component.length > 1 || selfLoop) components.push(component.sort()); + } + } + return components.sort((a, b) => b.length - a.length || a[0]!.localeCompare(b[0]!)); +} + +export function cyclicModules(graph: ImportGraph): string[] { + return cyclicComponents(graph).flat().sort(); +} + +export function evaluate(current: readonly string[], baseline: CycleBaseline): CycleVerdict { + const listed = new Set(baseline.cyclic); + const now = new Set(current); + return { + added: current.filter((path) => !listed.has(path)).sort(), + resolved: baseline.cyclic.filter((path) => !now.has(path)).sort(), + }; +} + +/** Never adds a member: a new cycle is fixed in the source, not admitted into the list. */ +export function updateBaseline(current: readonly string[], baseline: CycleBaseline): CycleBaseline { + const now = new Set(current); + return { cyclic: baseline.cyclic.filter((path) => now.has(path)).sort() }; +} + +export function loadBaseline(root: string): CycleBaseline { + return JSON.parse(readFileSync(join(root, BASELINE_REL), "utf8")) as CycleBaseline; +} + +if (import.meta.main) { + const root = resolve(import.meta.dir, ".."); + const components = cyclicComponents(scanGraph(root)); + const current = components.flat().sort(); + const sizes = components.map((component) => component.length).join(", "); + console.log(current.length + " modules on static import cycles in " + components.length + " components (" + sizes + ")"); + if (process.argv.includes("--seed")) { + writeFileSync(join(root, BASELINE_REL), JSON.stringify({ cyclic: current }, null, 2) + "\n"); + } else if (process.argv.includes("--update")) { + const next = updateBaseline(current, loadBaseline(root)); + writeFileSync(join(root, BASELINE_REL), JSON.stringify(next, null, 2) + "\n"); + console.log("baseline now lists " + next.cyclic.length + " modules"); + } else if (process.argv.includes("--components")) { + for (const component of components) console.log(component.length + ": " + component.join(" ")); + } +} diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 1a17320179c..26da2d9a117 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -937,6 +937,7 @@ "featherless-provider.test.ts": "providers", "fetch-header-timeout.test.ts": "server", "file-size-ratchet.test.ts": "ci-workflows", + "import-cycle-ratchet.test.ts": "ci-workflows", "fixture-dir-uniqueness.test.ts": "ci-workflows", "flash-route-image-modalities.test.ts": "providers", "format-result.test.ts": "web-search", @@ -1132,7 +1133,7 @@ "lab-public-wire-contract.test.ts": "lab", "lab-read-filter-validation.test.ts": "lab", "lab-read-surfaces.test.ts": "lab", - "launchd-repair.test.ts": "service", + "launchd-port-fallback.test.ts": "service", "launchd-repair.test.ts": "service", "legacy-shell-compat.test.ts": "responses", "live-call-bindings.test.ts": "server", "live-native-calls.test.ts": "server", "live-service-manager-guard.test.ts": "service", diff --git a/src/claude/desktop-first-party.ts b/src/claude/desktop-first-party.ts index fa28eda7d75..12a73cd5f1f 100644 --- a/src/claude/desktop-first-party.ts +++ b/src/claude/desktop-first-party.ts @@ -25,7 +25,7 @@ import type { OcxConfig } from "../types"; import { claudeConfigDir } from "./auth-detect"; import { inspectDesktop3pConfigLibrary } from "./desktop-3p"; import { claudeInterceptCaCertPath, ensureLocalInterceptCa } from "./intercept/local-ca"; -import { claudeInterceptEnabled, claudeInterceptProxyPort } from "./intercept/runtime"; +import { claudeInterceptEnabled, claudeInterceptProxyPort } from "./intercept/port-policy"; import { ensureClaudeInterceptProxyToken, readClaudeInterceptProxyToken } from "./intercept/proxy-auth"; import { applyClaudeInterceptSettings, diff --git a/src/claude/intercept/port-policy.ts b/src/claude/intercept/port-policy.ts new file mode 100644 index 00000000000..66fc33d41fa --- /dev/null +++ b/src/claude/intercept/port-policy.ts @@ -0,0 +1,30 @@ +import type { OcxConfig } from "../../types"; + +/** + * Port and enablement policy for the Claude intercept pair. + * + * These are pure functions of config, with no listener, CA, or picker dependency. They live + * apart from `./runtime` because the first-party Desktop path reads them too: importing them + * from `./runtime` made `desktop-first-party` reach the picker runtime, which reaches + * `desktop-first-party` back. Keeping the policy in a leaf keeps that pair acyclic. + */ + +export const CLAUDE_INTERCEPT_PORT_OFFSET = 100; + +export function claudeInterceptEnabled(config: Pick): boolean { + if (config.runtimeRole === "client") return false; + if (config.claudeCode?.enabled === false) return false; + return config.claudeCode?.intercept?.enabled !== false; +} + +export function claudeInterceptProxyPort(config: Pick, publicPort: number): number { + const configured = config.claudeCode?.intercept?.port; + if (typeof configured === "number" && Number.isInteger(configured) && configured >= 1 && configured <= 65535) return configured; + return publicPort + CLAUDE_INTERCEPT_PORT_OFFSET; +} + +/** Desktop's egress proxy for picker mode: the port after the intercept proxy (before it at 65535). */ +export function claudePickerProxyPort(config: Pick, publicPort: number): number { + const interceptPort = claudeInterceptProxyPort(config, publicPort); + return interceptPort < 65535 ? interceptPort + 1 : interceptPort - 1; +} diff --git a/src/claude/intercept/runtime.ts b/src/claude/intercept/runtime.ts index 4709b917970..25d98f693f9 100644 --- a/src/claude/intercept/runtime.ts +++ b/src/claude/intercept/runtime.ts @@ -13,6 +13,7 @@ import { discardPickerCaKey } from "./picker-ca"; import { preparePersistentPickerAuthority } from "./picker-ca-startup"; import type { PickerCaStore } from "./picker-ca-store"; import type { PickerRouteInput } from "./picker-models"; +import { CLAUDE_INTERCEPT_PORT_OFFSET, claudeInterceptEnabled, claudeInterceptProxyPort, claudePickerProxyPort } from "./port-policy"; import { createPickerRuntime, pickerDesired, type CreatePickerRuntimeOptions, type PickerRuntime } from "./picker-runtime"; import type { SecurityRunner } from "./picker-trust"; import { ensureClaudeInterceptProxyToken, readClaudeInterceptProxyToken } from "./proxy-auth"; @@ -42,25 +43,7 @@ const PICKER_AUTHORITY_ERROR_CODES = new Set([ "picker_ca_unsupported_platform", ]); -export const CLAUDE_INTERCEPT_PORT_OFFSET = 100; - -export function claudeInterceptEnabled(config: Pick): boolean { - if (config.runtimeRole === "client") return false; - if (config.claudeCode?.enabled === false) return false; - return config.claudeCode?.intercept?.enabled !== false; -} - -export function claudeInterceptProxyPort(config: Pick, publicPort: number): number { - const configured = config.claudeCode?.intercept?.port; - if (typeof configured === "number" && Number.isInteger(configured) && configured >= 1 && configured <= 65535) return configured; - return publicPort + CLAUDE_INTERCEPT_PORT_OFFSET; -} - -/** Desktop's egress proxy for picker mode: the port after the intercept proxy (before it at 65535). */ -export function claudePickerProxyPort(config: Pick, publicPort: number): number { - const interceptPort = claudeInterceptProxyPort(config, publicPort); - return interceptPort < 65535 ? interceptPort + 1 : interceptPort - 1; -} +export { CLAUDE_INTERCEPT_PORT_OFFSET, claudeInterceptEnabled, claudeInterceptProxyPort, claudePickerProxyPort } from "./port-policy"; export type ClaudeInterceptOutcome = { ok: true; state: ClaudeInterceptState } | { ok: false; reason: "disabled" | "client_role" | "ephemeral_port" | "port_in_use" | "stopped" | "failed"; port?: number; message?: string }; diff --git a/src/client/machine-listener.ts b/src/client/machine-listener.ts index fcbc63d941d..5954f2b45d3 100644 --- a/src/client/machine-listener.ts +++ b/src/client/machine-listener.ts @@ -107,9 +107,7 @@ export function startMachineListener( const machineApiDeps: MachineApiDeps = { sync: deps.machineApi?.sync ?? syncConnectedClient, disconnect: deps.machineApi?.disconnect ?? disconnectClient, - scheduleStandaloneRecycle: deps.machineApi?.scheduleStandaloneRecycle ?? (tokenFingerprint => { - void import("./runtime").then(module => module.scheduleStandaloneRecycle(tokenFingerprint)); - }), + scheduleStandaloneRecycle: deps.machineApi?.scheduleStandaloneRecycle ?? (() => {}), hubReachability: deps.machineApi?.hubReachability ?? (() => hubReachability), setHubReachability: deps.machineApi?.setHubReachability ?? (value => { hubReachability = value; }), ...(linkMode ? { linkHomeName: () => linkHomeName(connection, sidecarOrNull) } : {}), diff --git a/src/client/runtime.ts b/src/client/runtime.ts index a42b1ea1ade..9ade2340e33 100644 --- a/src/client/runtime.ts +++ b/src/client/runtime.ts @@ -250,6 +250,7 @@ export async function bindClientListener( try { const server = startListener(port, { state: request.state, + machineApi: { scheduleStandaloneRecycle }, ...(request.linkStatus ? { linkStatus: request.linkStatus } : {}), ...(request.linkKeySource ? { linkKeySource: request.linkKeySource } : {}), ...(request.linkTunnel ? { linkTunnel: request.linkTunnel } : {}), diff --git a/src/codex/account-label.ts b/src/codex/account-label.ts index 046462670bb..21858d39ad7 100644 --- a/src/codex/account-label.ts +++ b/src/codex/account-label.ts @@ -2,7 +2,7 @@ import { createHash, randomBytes } from "node:crypto"; import type { CodexAccount, OcxConfig } from "../types"; import type { CodexAuthContext } from "./auth-context"; import type { ProviderApiKeySelection } from "../types/provider"; -import { MAIN_CODEX_ACCOUNT_ID } from "./main-account"; +import { MAIN_CODEX_ACCOUNT_ID } from "./account-id"; export const CODEX_ACCOUNT_LOG_LABEL_RE = /^p[a-f0-9]{6}$/; diff --git a/src/codex/account-lifecycle.ts b/src/codex/account-lifecycle.ts index 8e6e51f9ca2..65e35aa5f10 100644 --- a/src/codex/account-lifecycle.ts +++ b/src/codex/account-lifecycle.ts @@ -12,7 +12,9 @@ import { clearAccountNeedsReauth } from "./account-runtime-state"; import { readCodexTokensResult } from "./auth-collision"; import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account"; import { clearAccountQuota } from "./quota"; -import { clearCodexUpstreamHealthForAccount, clearThreadAccountMapForAccount } from "./routing"; +import { forgetManualPreference } from "./routing/active-account"; +import { deleteAllHealthForAccount } from "./routing/health-store"; +import { clearThreadAccountMapForAccount } from "./routing/thread-affinity"; import { clearCodexPoolRefreshFailure } from "./pool-refresh-backoff"; import { invalidateCodexWebSocketsForAccount } from "./websocket-registry"; @@ -47,7 +49,11 @@ export function purgeCodexAccountRuntimeState(accountId: string): void { clearAccountNeedsReauth(accountId); clearAccountQuota(accountId); clearThreadAccountMapForAccount(accountId); - clearCodexUpstreamHealthForAccount(accountId); + // Deliberately not via `./routing`: the account-delete path only needs the health rows and + // the manual preference gone, and importing the router here closed a cycle through + // routing/health-store and routing/active-account. + deleteAllHealthForAccount(accountId); + forgetManualPreference(accountId); clearCodexPoolRefreshFailure(accountId); if (accountId === MAIN_CODEX_ACCOUNT_ID) { clearMainAccountInfoCache(); diff --git a/src/codex/app-server-processes.ts b/src/codex/app-server-processes.ts index c7fa8b16fa5..b1e9e67280f 100644 --- a/src/codex/app-server-processes.ts +++ b/src/codex/app-server-processes.ts @@ -14,7 +14,7 @@ import { resolveTrustedWindowsPowerShellExe, resolveTrustedWindowsTaskkillExe, } from "../lib/windows-elevation"; -import { readCodexCatalogPath } from "./catalog/parsing"; +import { readCodexCatalogPath } from "./paths"; export const STALE_CODEX_APP_SERVER_HINT = "If Codex still shows an older model list, run `ocx sync --restart-codex`: it restarts the long-lived app-server " diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index bb857fbe33c..1db62301cd2 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -2,10 +2,16 @@ import type { VendorContextInference } from "./vendor-context"; import { isCodexControlPlaneModel } from "../control-plane-models"; import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs"; +import { copyFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"; import { delimiter, dirname, join, resolve } from "node:path"; -import { atomicWriteFile, expandUserPath, getConfigDir, loadConfig, ultraFastTierEnabled, websocketsEnabled } from "../../config"; -import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, readRootTomlString, resolveCodexConfigPath } from "../paths"; +import { atomicWriteFile, getConfigDir, loadConfig, ultraFastTierEnabled, websocketsEnabled } from "../../config"; +import { + activeCodexConfigPath, + activeCodexModelsCachePath, + isDefaultCatalogPath, + readCodexCatalogPath, + readRootTomlString, +} from "../paths"; 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"; @@ -50,46 +56,22 @@ export function catalogBackupPathFor(catalogPath: string): string { return join(getConfigDir(), `catalog-backup-${id}.json`); } -export function samePath(a: string, b: string): boolean { - const left = resolve(a); - const right = resolve(b); - return process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right; -} - -export function activeCodexHome(): string | null { - const raw = process.env.CODEX_HOME?.trim(); - if (!raw) return null; - const path = resolve(expandUserPath(raw)); - try { - return realpathSync.native(path); - } catch { - return path; - } -} - -export function activeCodexConfigPath(): string { - const home = activeCodexHome(); - return home ? join(home, "config.toml") : CODEX_CONFIG_PATH; -} - -export function activeDefaultCatalogPath(): string { - const home = activeCodexHome(); - return home ? join(home, "opencodex-catalog.json") : DEFAULT_CATALOG_PATH; -} - -export function activeCodexModelsCachePath(): string { - const home = activeCodexHome(); - return home ? join(home, "models_cache.json") : CODEX_MODELS_CACHE_PATH; -} - -export function resolveActiveCodexConfigPath(path: string): string { - const home = activeCodexHome(); - return home ? resolve(home, path) : resolveCodexConfigPath(path); -} - -export function isDefaultCatalogPath(path: string): boolean { - return samePath(path, activeDefaultCatalogPath()); -} +/** + * Active-home path resolution now lives in `../paths`, next to the constants it + * falls back to. It is re-exported here so existing importers keep working; new + * code that only needs a path should import from `../paths` directly. + */ +export { + samePath, + activeCodexHome, + activeCodexConfigPath, + activeDefaultCatalogPath, + activeCodexModelsCachePath, + resolveActiveCodexConfigPath, + isDefaultCatalogPath, + readCodexCatalogPath, + readCodexCatalogPathForHome, +} from "../paths"; /** Stable nonsemantic ownership marker for rows projected from config.customModels. */ export const CODEX_CUSTOM_MODEL_CATALOG_KIND = "custom-model-v1"; @@ -254,40 +236,6 @@ export function shouldExposeRoutedModel(model: CatalogModel): boolean { return !isMediaGenerationModelId(model.id); } -export function readCodexCatalogPath(): string { - const home = activeCodexHome(); - if (home) return readCodexCatalogPathForHome(home); - try { - const configPath = activeCodexConfigPath(); - if (existsSync(configPath)) { - const toml = readFileSync(configPath, "utf-8"); - const path = readRootTomlString(toml, "model_catalog_json"); - if (path) return resolveActiveCodexConfigPath(path); - } - } catch { /* ignore */ } - return activeDefaultCatalogPath(); -} - -/** - * Resolve the configured catalog without consulting ambient CODEX_HOME again. - * - * `configText` is for a caller that has already read that same `config.toml` under - * its own constraints - the prompt-text probe reads it bounded, on the request - * thread - so resolving the catalog does not cost a second, unbounded read of the - * file the caller is holding. Omitting it keeps the original behaviour. - */ -export function readCodexCatalogPathForHome(codexHome: string, configText?: string): string { - try { - const configPath = join(codexHome, "config.toml"); - if (configText !== undefined || existsSync(configPath)) { - const toml = configText ?? readFileSync(configPath, "utf-8"); - const path = readRootTomlString(toml, "model_catalog_json"); - if (path) return resolve(codexHome, path); - } - } catch { /* ignore */ } - return join(codexHome, "opencodex-catalog.json"); -} - /** * Read the configured auto-review model from the root of Codex's config.toml (issue #1225). * Stamped onto catalog entries as `auto_review_model_override` during sync so the auto-review diff --git a/src/codex/native-profile-startup.ts b/src/codex/native-profile-startup.ts index 076425b44ed..ade3d036480 100644 --- a/src/codex/native-profile-startup.ts +++ b/src/codex/native-profile-startup.ts @@ -3,7 +3,7 @@ import { loadConfig } from "../config"; import { initializeMainAccountPolicyBinding } from "./account-lifecycle"; import { isMainAccountHardLockEnabled } from "./main-account-hard-lock"; import { clearAccountNeedsReauth } from "./account-runtime-state"; -import { MAIN_CODEX_ACCOUNT_ID } from "./main-account"; +import { MAIN_CODEX_ACCOUNT_ID } from "./account-id"; import { probeNativeProfileRecoveryState, resolveNativeProfileContext, diff --git a/src/codex/paths.ts b/src/codex/paths.ts index 7a9c6569ebc..06427dd97a2 100644 --- a/src/codex/paths.ts +++ b/src/codex/paths.ts @@ -1,4 +1,4 @@ -import { readFileSync, realpathSync, statSync } from "node:fs"; +import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"; import { isAbsolute, join, resolve } from "node:path"; import { expandUserPath } from "../config"; import { defaultCodexHome } from "./home"; @@ -144,3 +144,88 @@ export function readRootTomlString(content: string, key: string): string | null export function resolveCodexConfigPath(path: string): string { return isAbsolute(path) ? path : join(CODEX_HOME, path); } + +// --------------------------------------------------------------------------- +// Active Codex home resolution. +// +// These resolve `CODEX_HOME` at call time rather than at import time, which is +// what lets a test or a sibling-home probe point fixture state somewhere else +// after this module has already loaded. They live here, next to the constants +// they fall back to, so a caller that only needs a path never has to reach the +// catalog parser. +// --------------------------------------------------------------------------- + +export function samePath(a: string, b: string): boolean { + const left = resolve(a); + const right = resolve(b); + return process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right; +} + +export function activeCodexHome(): string | null { + const raw = process.env.CODEX_HOME?.trim(); + if (!raw) return null; + const path = resolve(expandUserPath(raw)); + try { + return realpathSync.native(path); + } catch { + return path; + } +} + +export function activeCodexConfigPath(): string { + const home = activeCodexHome(); + return home ? join(home, "config.toml") : CODEX_CONFIG_PATH; +} + +export function activeDefaultCatalogPath(): string { + const home = activeCodexHome(); + return home ? join(home, "opencodex-catalog.json") : DEFAULT_CATALOG_PATH; +} + +export function activeCodexModelsCachePath(): string { + const home = activeCodexHome(); + return home ? join(home, "models_cache.json") : CODEX_MODELS_CACHE_PATH; +} + +export function resolveActiveCodexConfigPath(path: string): string { + const home = activeCodexHome(); + return home ? resolve(home, path) : resolveCodexConfigPath(path); +} + +export function isDefaultCatalogPath(path: string): boolean { + return samePath(path, activeDefaultCatalogPath()); +} + +export function readCodexCatalogPath(): string { + const home = activeCodexHome(); + if (home) return readCodexCatalogPathForHome(home); + try { + const configPath = activeCodexConfigPath(); + if (existsSync(configPath)) { + const toml = readFileSync(configPath, "utf-8"); + const path = readRootTomlString(toml, "model_catalog_json"); + if (path) return resolveActiveCodexConfigPath(path); + } + } catch { /* ignore */ } + return activeDefaultCatalogPath(); +} + +/** + * Resolve the configured catalog without consulting ambient CODEX_HOME again. + * + * `configText` is for a caller that has already read that same `config.toml` under + * its own constraints - the prompt-text probe reads it bounded, on the request + * thread - so resolving the catalog does not cost a second, unbounded read of the + * file the caller is holding. Omitting it keeps the original behaviour. + */ +export function readCodexCatalogPathForHome(codexHome: string, configText?: string): string { + try { + const configPath = join(codexHome, "config.toml"); + if (configText !== undefined || existsSync(configPath)) { + const toml = configText ?? readFileSync(configPath, "utf-8"); + const path = readRootTomlString(toml, "model_catalog_json"); + if (path) return resolve(codexHome, path); + } + } catch { /* ignore */ } + return join(codexHome, "opencodex-catalog.json"); +} diff --git a/src/codex/refresh.ts b/src/codex/refresh.ts index cd7eb309fb6..cc9da05e3df 100644 --- a/src/codex/refresh.ts +++ b/src/codex/refresh.ts @@ -1,12 +1,12 @@ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; -import { invalidateCodexModelsCache, syncCatalogModels } from "./catalog"; +import { invalidateCodexModelsCache, syncCatalogModels } from "./catalog/retained-sync"; import type { ComboCatalogOmission } from "./catalog/aggregation"; import { getCodexHome } from "./paths"; import { withCatalogWriteSerialization, CatalogWritePermitRefusal } from "./catalog-write-serialization"; import { replaceCodexModelsCache } from "./internal/catalog-writer"; import type { OcxConfig } from "../types"; -import type { CodexCatalogSyncOptions } from "./catalog/sync"; +import type { CodexCatalogSyncOptions } from "./catalog/retained-sync"; export interface CodexCatalogRefreshResult { added: number; diff --git a/src/codex/routing/health-store.ts b/src/codex/routing/health-store.ts index 58ce2797aae..ef3c3514835 100644 --- a/src/codex/routing/health-store.ts +++ b/src/codex/routing/health-store.ts @@ -1,6 +1,6 @@ import { isCodexAccountGenerationLive } from "../account-store"; import { NATIVE_RESERVE_MODEL } from "../catalog/native-models"; -import { MAIN_CODEX_ACCOUNT_ID } from "../main-account"; +import { MAIN_CODEX_ACCOUNT_ID } from "../account-id"; import { POOL_KEY_CODEX } from "../pool-rotation"; import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; import type { OcxConfig } from "../../types"; diff --git a/src/codex/routing/thread-affinity.ts b/src/codex/routing/thread-affinity.ts index 224169fb9c7..1a44437177f 100644 --- a/src/codex/routing/thread-affinity.ts +++ b/src/codex/routing/thread-affinity.ts @@ -1,5 +1,5 @@ import { isCodexAccountGenerationLive, readCodexAccountRecord } from "../account-store"; -import { MAIN_CODEX_ACCOUNT_ID } from "../main-account"; +import { MAIN_CODEX_ACCOUNT_ID } from "../account-id"; import { retainedUtf8Bytes } from "../../lib/admission"; import { clearAllCodexPoolRefreshFailures } from "../pool-refresh-backoff"; import type { CodexThreadLineage } from "../lineage"; diff --git a/src/codex/subagent-model-fallback.ts b/src/codex/subagent-model-fallback.ts index 7f9e22648dd..ed0d6c8b0b3 100644 --- a/src/codex/subagent-model-fallback.ts +++ b/src/codex/subagent-model-fallback.ts @@ -591,7 +591,7 @@ export function maybePrimeSubagentQuota( if (subagentQuotaPrimeForTests) { await subagentQuotaPrimeForTests(config, "subagent-spawn"); } else { - const { primeCodexPoolQuotas } = await import("./auth-api"); + const { primeCodexPoolQuotas } = await import("./auth-api/pool-mode-gate"); await primeCodexPoolQuotas(config, "subagent-spawn"); } quotaPrimedAt.set("global", Date.now()); diff --git a/src/grok/sync.ts b/src/grok/sync.ts index f57f8d36354..491c1f6cf03 100644 --- a/src/grok/sync.ts +++ b/src/grok/sync.ts @@ -18,7 +18,7 @@ export interface GrokSyncDeps { } async function defaultFetchAllModels(config: OcxConfig): Promise { - const { fetchAllModels } = await import("../server/management-api"); + const { fetchAllModels } = await import("../server/management/shared"); return fetchAllModels(config); } diff --git a/src/providers/api-key-selection.ts b/src/providers/api-key-selection.ts index 689bbebaae8..d06ad72bc63 100644 --- a/src/providers/api-key-selection.ts +++ b/src/providers/api-key-selection.ts @@ -5,7 +5,7 @@ import { publishAccountSelection } from "../lib/account-selection-events"; import { clearModelCache } from "../codex/model-cache"; import type { OcxConfig, OcxProviderConfig } from "../types"; import type { ProviderApiKeySelection } from "../types/provider"; -import { routedProviderConfig } from "../router"; +import { routedProviderConfig } from "./routed-config"; import { OPENCODE_GO_SESSION_HEADER } from "./opencode-go-transport"; import { resolveProviderTransport, XAI_GROK_COMPATIBILITY, type OcxProviderTransport } from "./xai-transport"; import { captureProviderApiKeySelection } from "./api-key-selection-capture"; diff --git a/src/providers/key-failover.ts b/src/providers/key-failover.ts index f0eb227ff1d..36fefe09ae5 100644 --- a/src/providers/key-failover.ts +++ b/src/providers/key-failover.ts @@ -13,7 +13,7 @@ export { clearKeyCooldowns, getKeyCooldownUntil, sweepExpiredApiKeyCooldowns } f import { commitProviderApiKeySelection, providerApiKeySelectionIsCurrent } from "./api-key-selection"; import { RequestPacingQueueOverloadError } from "./request-pacing"; import type { ProviderApiKeySelection } from "../types/provider"; -import { routedProviderConfig } from "../router"; +import { routedProviderConfig } from "./routed-config"; import { getProviderRegistryEntry } from "./registry"; import { isCanonicalCommandCodeBaseUrl, normalizedBaseUrl } from "./quota/vendor-probes-key"; import type { OcxConfig, OcxProviderConfig, RateLimitRetryPolicy, ResetReplayPolicy, TransientRetryPolicy } from "../types"; diff --git a/src/providers/quota.ts b/src/providers/quota.ts index b67e1a867fc..e7b41f3edaf 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -68,7 +68,6 @@ import { } from "./quota/vendor-probes-oauth"; import { fetchCommandCodeQuota, fetchKimiQuota, keyQuotaReaderForProvider } from "./quota/vendor-probes-key"; import { antigravityQuotaDiagnosticIdentity, fetchAntigravityQuota, probeAntigravityUsageQuota } from "./quota/antigravity"; -import { projectAccountQuotaRow } from "./quota/account-row"; import { persistKiroAccountState } from "./kiro-account-state-disk"; import { kiroProbeCurrent, kiroProbeIdentity } from "./quota/kiro-account-probe"; export type { ProviderQuota, ProviderQuotaCreditsUsd, ProviderQuotaWindow } from "./quota-types"; @@ -539,6 +538,7 @@ export async function fetchProviderAccountQuotas( if (!set) return []; return mapQuotaRoster(set.accounts, async account => { const entry = await fetchAccountQuota(provider, account.id, forceRefresh, providerConfig, liveConfig); + const { projectAccountQuotaRow } = await import("./quota/account-row"); return projectAccountQuotaRow(provider, account.id, entry, providerConfig); }); } diff --git a/src/providers/routed-config.ts b/src/providers/routed-config.ts new file mode 100644 index 00000000000..07fde43897e --- /dev/null +++ b/src/providers/routed-config.ts @@ -0,0 +1,339 @@ +/** + * Request-time provider resolution: merge a saved config row with its registry entry. + * + * This lives outside `src/router.ts` because `providers/api-key-selection.ts` needs exactly + * one function from the router, and importing the router for it dragged the whole routing + * graph into an import cycle. The router re-exports both symbols below so every existing + * import keeps working. + */ +import type { OcxProviderConfig } from "../types"; +import { providerUsesKeyAuthOverride, resolveProviderApiKey } from "./key-store"; +import { GCP_CREDENTIAL_MARKER_PREFIX } from "../lib/gcp-adc"; +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 { cloneFastWire } from "./fastwire"; +import { fastSwitchOff } from "./fast-opt-in"; +import { + providerMatchesRegistryTransportWithStaticGuards, + providerSupportsLiveModelDiscovery, +} from "./static-model-discovery"; +import { resolveModelPolicy } from "./resolved-model-policy"; + +/** Same endpoint modulo surrounding space and trailing slashes — matches `matchBaseUrlChoice`. */ +function isSameEndpoint(a: string, b: string): boolean { + return a.trim().replace(/\/+$/, "") === b.trim().replace(/\/+$/, ""); +} + +/** + * Origin of a user-configured URL, with the path withheld. + * + * A configured `baseUrl` is user-controlled and its path may itself be the credential — an + * account-scoped route token such as `https://proxy.example/v1/8fK2mP7qR4nV6x` is opaque and + * high-entropy, so it matches none of the prefix patterns in `redactSecretString`. Pattern + * redaction cannot be trusted for this value, so no path segment is logged at all. `URL.origin` + * also excludes userinfo, query and fragment. + * + * `…/…` marks that a path was present without revealing it, so a reader can tell an origin-only + * config apart from one whose path was dropped. + */ +function configuredOriginForLog(url: string): string { + try { + const parsed = new URL(url.trim()); + // "null" is what URL.origin yields for non-special schemes; treat it as unusable. + if (!parsed.origin || parsed.origin === "null") return "(unloggable URL)"; + const hasPath = parsed.pathname !== "" && parsed.pathname !== "/"; + return hasPath ? `${parsed.origin}/…` : parsed.origin; + } catch { + return "(unparseable URL)"; + } +} + +// `routedProviderConfig` runs per request, so warn once per (provider, discarded, effective) triple. +// Keyed by the URLs too: editing config.json to a different wrong value warns again. +const discardedBaseUrlWarnings = new Set(); +let lastWarningReconciledGeneration = 0; + +export function reconcileRouterWarningMemos(generation: number): number { + if (generation <= lastWarningReconciledGeneration) return 0; + const removed = discardedBaseUrlWarnings.size; + discardedBaseUrlWarnings.clear(); + lastWarningReconciledGeneration = generation; + return removed; +} + +/** + * A pinned registry entry — non-template `baseUrl`, no `allowBaseUrlOverride` — outranks a saved + * `baseUrl`. Dropping it silently is a footgun: requests go to an endpoint the user never + * configured, and a wrong-region or wrong-account URL then surfaces only as a 401 with nothing + * pointing back at the discarded setting. + * + * Warns rather than throws. The effective route is exactly what it was before, so a hard error + * here would break configs that route fine today (a stale `baseUrl` left over from an earlier + * provider is harmless whenever it names the same endpoint the registry pins). + */ +function warnIfBaseUrlDiscarded(providerName: string, userBaseUrl: string, effectiveBaseUrl: string): void { + if (isSameEndpoint(userBaseUrl, effectiveBaseUrl)) return; + // Asymmetric on purpose. Past the guard above, `effectiveBaseUrl` is necessarily + // `registryEntry.baseUrl`: the caller passes the resolved URL, and whenever that resolution + // kept the user's value the two are equal and we have already returned. So the effective side + // is a constant from this repo's registry and safe to print in full — it is also the useful + // half, naming the endpoint requests will actually use. The configured side is untrusted. + const discarded = configuredOriginForLog(userBaseUrl); + const effective = redactSecretString(redactUrlForLog(effectiveBaseUrl)); + // Key off the logged forms: no raw credential is retained for the process lifetime, and + // rotating a key embedded in the URL no longer re-warns about the same endpoint mismatch. + // Coarser than the raw URLs — two bad paths on one host warn once, which is the right grain. + const key = `${providerName} | ${discarded} | ${effective}`; + if (discardedBaseUrlWarnings.has(key)) return; + discardedBaseUrlWarnings.add(key); + console.warn( + // Routing is what this warning speaks for: an adapter may adjust the endpoint again + // downstream (kiro re-derives the region), so do not promise where the request lands. + `⚠️ config.json provider "${providerName}": configured baseUrl ${discarded} is ignored` + + ` because this provider's endpoint is fixed at ${effective}. A URL saved for a different` + + ` account or region is a common cause of 401s here — drop it, or use the provider whose endpoint matches.`, + ); +} + +function usableResolvedApiKey(apiKey: string | undefined): string | undefined { + // A `gcp-sa:` marker must pass through UNCHANGED: it resolves to `undefined` here (it is not a + // keychain/env ref, and the marker itself is not a literal), and replacing it would silently + // drop the marker from the routed config — the vertex adapter would then read + // `gcpCredentialMarkerAccount(undefined)` and fall back to host ADC instead of the configured + // service account. Return the marker verbatim so the adapter passes its account per request. + if (typeof apiKey === "string" && apiKey.startsWith(GCP_CREDENTIAL_MARKER_PREFIX)) return apiKey; + const resolved = resolveProviderApiKey(apiKey); + return typeof resolved === "string" && resolved.trim().length > 0 ? resolved : undefined; +} + +export function routedProviderConfig(providerName: string, provider: OcxProviderConfig): OcxProviderConfig { + provider = { ...provider, _apiKeyAttempt: provider._apiKeyAttempt ?? captureProviderApiKeySelection(provider) }; + const registryEntry = PROVIDER_REGISTRY.find(entry => entry.id === providerName); + if (!registryEntry || !providerMatchesRegistryTransportWithStaticGuards(providerName, provider)) { + assertProviderDestinationAllowed(providerName, provider); + // A row whose adapter no longer matches its registry entry still reaches the Responses + // adapter when one model opts in through `modelAdapters` — a Volcengine Coding Plan config + // saved on Chat, for instance. The replay-drop flag belongs to the DESTINATION rather than + // to the provider-wide wire, so it is filled here too; without it that continuation forwards + // 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); + return { + ...provider, + apiKey: usableResolvedApiKey(provider.apiKey), + ...(provider.dropResponsesReasoningItems === undefined && destination?.dropResponsesReasoningItems !== undefined + ? { dropResponsesReasoningItems: destination.dropResponsesReasoningItems } + : {}), + }; + } + const resolvedApiKey = usableResolvedApiKey(provider.apiKey); + const staticModelCatalog = !providerSupportsLiveModelDiscovery(providerName, provider); + const repairLegacyMimoFreeAuth = providerName === "mimo-free" + && staticModelCatalog + && (provider.authMode === undefined || provider.authMode === "local"); + const explicitKeyOverride = providerUsesKeyAuthOverride(registryEntry, provider, resolvedApiKey); + const canonicalAuthMode = explicitKeyOverride + ? "key" + : repairLegacyMimoFreeAuth + ? "key" + : registryEntry.authKind === "forward" || registryEntry.authKind === "oauth" + ? registryEntry.authKind + : provider.authMode === "forward" ? undefined : provider.authMode; + const staticPolicy = resolveModelPolicy({ + providerName, + modelId: "__provider_static__", + provider, + registryEntry, + transportMatchedRegistry: true, + ...(canonicalAuthMode ? { effectiveAuth: { authMode: canonicalAuthMode } } : {}), + }).provider; + const reasoningEffortMap = staticPolicy.reasoningEffortMap; + const modelReasoningEffortMap = staticPolicy.modelReasoningEffortMap; + const modelReasoningEfforts = staticPolicy.modelReasoningEfforts; + const modelDefaultReasoningEfforts = staticPolicy.modelDefaultReasoningEfforts; + const modelContextWindows = staticPolicy.modelContextWindows; + const modelInputModalities = staticPolicy.modelInputModalities; + // Registry static headers are documented as applying to every upstream request, so they are + // filled at resolve time rather than only at seed time: a config written before a header + // existed, or one carrying any header of its own, would otherwise never receive it. User + // headers win, matched case-insensitively so an override replaces rather than duplicates. + const headers = staticPolicy.headers; + const modelMaxInputTokens = staticPolicy.modelMaxInputTokens; + const modelMaxOutputTokens = staticPolicy.modelMaxOutputTokens; + const modelSupportsServiceTier = staticPolicy.modelSupportsServiceTier; + const modelSupportsVerbosity = staticPolicy.modelSupportsVerbosity; + const noVisionModels = staticPolicy.noVisionModels; + const noReasoningModels = staticPolicy.noReasoningModels; + const noTemperatureModels = staticPolicy.noTemperatureModels; + const noTopPModels = staticPolicy.noTopPModels; + const noStopModels = staticPolicy.noStopModels; + const noPenaltyModels = staticPolicy.noPenaltyModels; + const noJsonSchemaModels = staticPolicy.noJsonSchemaModels; + const autoToolChoiceOnlyModels = staticPolicy.autoToolChoiceOnlyModels; + const preserveReasoningContentModels = staticPolicy.preserveReasoningContentModels; + const requiresReasoningPlaceholderModels = staticPolicy.requiresReasoningPlaceholderModels; + const reasoningSplitModels = staticPolicy.reasoningSplitModels; + const inlineThinkTagModels = staticPolicy.inlineThinkTagModels; + const reasoningDetailsModels = staticPolicy.reasoningDetailsModels; + const thinkingToggleModels = staticPolicy.thinkingToggleModels; + const thinkingBudgetModels = staticPolicy.thinkingBudgetModels; + const registryBaseUrlIsTemplate = /\{[^}]*\}/.test(registryEntry.baseUrl); + const userBaseUrl = typeof provider.baseUrl === "string" ? provider.baseUrl.trim() : ""; + const userBaseUrlIsResolved = userBaseUrl.length > 0 && !/\{[^}]*\}/.test(userBaseUrl); + if (registryEntry.allowBaseUrlOverride && !userBaseUrlIsResolved) { + throw new Error(`Invalid baseUrl for provider "${providerName}": expected a nonblank URL without unresolved placeholders`); + } + // Registry template URLs are presets; local/self-hosted entries opt in explicitly. + const baseUrl = (registryBaseUrlIsTemplate || registryEntry.allowBaseUrlOverride) && userBaseUrlIsResolved + ? userBaseUrl + : registryEntry.baseUrl; + if (userBaseUrlIsResolved) warnIfBaseUrlDiscarded(providerName, userBaseUrl, baseUrl); + assertProviderDestinationAllowed(providerName, { baseUrl, allowPrivateNetwork: provider.allowPrivateNetwork }); + + const resolved: OcxProviderConfig = { + ...provider, + adapter: registryEntry.adapter, + baseUrl, + ...(provider.responsesPath === undefined && registryEntry.responsesPath !== undefined + ? { responsesPath: registryEntry.responsesPath } + : {}), + ...(provider.chatCompletionsPath === undefined && registryEntry.chatCompletionsPath !== undefined + ? { chatCompletionsPath: registryEntry.chatCompletionsPath } + : {}), + ...(provider.requiresAdjacentResponsesToolResults === undefined + && registryEntry.requiresAdjacentResponsesToolResults !== undefined + ? { requiresAdjacentResponsesToolResults: registryEntry.requiresAdjacentResponsesToolResults } + : {}), + ...(provider.requiresPairedResponsesToolResults === undefined + && registryEntry.requiresPairedResponsesToolResults !== undefined + ? { requiresPairedResponsesToolResults: registryEntry.requiresPairedResponsesToolResults } + : {}), + ...(provider.annotateEmptyToolOutputs === undefined + && registryEntry.annotateEmptyToolOutputs !== undefined + ? { annotateEmptyToolOutputs: registryEntry.annotateEmptyToolOutputs } + : {}), + ...(provider.fastWire === undefined && registryEntry.fastWire !== undefined + ? { + fastWire: cloneFastWire(registryEntry.fastWire), + } + : {}), + ...(provider.supportsServiceTier === undefined && registryEntry.supportsServiceTier !== undefined + ? { supportsServiceTier: registryEntry.supportsServiceTier } + : {}), + // An off Fast switch is a provider-wide denial on the runtime provider, so a Fast policy + // resolved without the provider name still refuses (providerFastSwitchOff). + ...(fastSwitchOff(provider, registryEntry) ? { supportsServiceTier: false } : {}), + // Registry-only web-search capability: without this backfill a saved provider row reaches + // the Responses adapter with the flag `undefined`, so the capability gate added in #2262 + // reads "unclassified" and forwards Codex's OpenAI-only `web_search` config fields. xAI + // rejects the whole request before inference ("Argument not supported: + // external_web_access"), which killed every routed Grok turn on the Responses lane. + // enrichProviderFromRegistry() already fills this, but the request path resolves through + // routedProviderConfig() and never called it. + ...(provider.supportsOpenAiWebSearchToolFields === undefined + && registryEntry.supportsOpenAiWebSearchToolFields !== undefined + ? { supportsOpenAiWebSearchToolFields: registryEntry.supportsOpenAiWebSearchToolFields } + : {}), + ...(provider.supportsResponsesCustomTools === undefined && registryEntry.supportsResponsesCustomTools !== undefined + ? { supportsResponsesCustomTools: registryEntry.supportsResponsesCustomTools } + : {}), + ...(provider.preserveResponsesReasoningContent === undefined && registryEntry.preserveResponsesReasoningContent !== undefined + ? { preserveResponsesReasoningContent: registryEntry.preserveResponsesReasoningContent } + : {}), + ...(provider.preserveResponsesInputItemIds === undefined && registryEntry.preserveResponsesInputItemIds !== undefined + ? { preserveResponsesInputItemIds: registryEntry.preserveResponsesInputItemIds } + : {}), + ...(provider.preserveResponsesMessageMetadata === undefined && registryEntry.preserveResponsesMessageMetadata !== undefined + ? { preserveResponsesMessageMetadata: registryEntry.preserveResponsesMessageMetadata } + : {}), + ...(provider.dropResponsesReasoningItems === undefined && registryEntry.dropResponsesReasoningItems !== undefined + ? { dropResponsesReasoningItems: registryEntry.dropResponsesReasoningItems } + : {}), + // The request path resolves through routedProviderConfig() and never calls + // enrichProviderFromRegistry(), so a saved provider row written before the + // registry learned this flag must be backfilled here or route.provider never + // carries it and the showThinkingSummary opt-in stays dead. + ...(provider.showThinkingSummary === undefined && registryEntry.showThinkingSummary !== undefined + ? { showThinkingSummary: registryEntry.showThinkingSummary } + : {}), + // Registry-only client-facing repair policy (#938): fill only when the + // saved provider has no explicit policy; clone so runtime never aliases + // the registry constant. + ...(provider.responsesItemIdRepair === undefined && registryEntry.responsesItemIdRepair + ? { + responsesItemIdRepair: { + ...(registryEntry.responsesItemIdRepair.message ? { message: [...registryEntry.responsesItemIdRepair.message] } : {}), + ...(registryEntry.responsesItemIdRepair.reasoning ? { reasoning: [...registryEntry.responsesItemIdRepair.reasoning] } : {}), + ...(registryEntry.responsesItemIdRepair.repairMissingTerminalIds !== undefined + ? { repairMissingTerminalIds: registryEntry.responsesItemIdRepair.repairMissingTerminalIds } + : {}), + ...(registryEntry.responsesItemIdRepair.repairInvalidIds !== undefined + ? { repairInvalidIds: registryEntry.responsesItemIdRepair.repairInvalidIds } + : {}), + }, + } + : {}), + authMode: canonicalAuthMode, + apiKey: resolvedApiKey, + ...(staticModelCatalog ? { liveModels: false } : {}), + ...(headers ? { headers } : {}), + // Backfill the Google wire mode + Vertex project/location from the registry when the user + // config omits them, so a minimal `google-vertex`/`google-antigravity` entry still routes + // through the correct branch (CCA/Vertex) instead of falling back to AI Studio. + ...(provider.googleMode === undefined && registryEntry.googleMode !== undefined ? { googleMode: registryEntry.googleMode } : {}), + ...(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 } : {}), + ...((provider.reasoningEfforts === undefined || hasLegacyClinePassReasoningEfforts(providerName, provider)) + && registryEntry.reasoningEfforts !== undefined + ? { reasoningEfforts: [...registryEntry.reasoningEfforts] } + : {}), + ...(provider.escapeBuiltinToolNames === undefined && registryEntry.escapeBuiltinToolNames !== undefined ? { escapeBuiltinToolNames: registryEntry.escapeBuiltinToolNames } : {}), + ...(provider.keyOptional === undefined && registryEntry.keyOptional !== undefined ? { keyOptional: registryEntry.keyOptional } : {}), + ...(provider.modelSuffixBracketStrip === undefined && registryEntry.modelSuffixBracketStrip !== undefined ? { modelSuffixBracketStrip: registryEntry.modelSuffixBracketStrip } : {}), + // Scalar backfill: a persisted config created before the flag shipped inherits the registry + // opt-in, while an explicit user `false` keeps overriding registry `true`. + ...(provider.parallelToolCalls === undefined && registryEntry.parallelToolCalls !== undefined ? { parallelToolCalls: registryEntry.parallelToolCalls } : {}), + ...(provider.promptCacheKey === undefined && registryEntry.promptCacheKey !== undefined ? { promptCacheKey: registryEntry.promptCacheKey } : {}), + ...(provider.chatServiceTier === undefined && registryEntry.chatServiceTier !== undefined ? { chatServiceTier: registryEntry.chatServiceTier } : {}), + ...(provider.openaiChatEofTolerance === undefined && registryEntry.openaiChatEofTolerance !== undefined + ? { openaiChatEofTolerance: registryEntry.openaiChatEofTolerance } + : {}), + ...(provider.reasoningWireFormat === undefined && registryEntry.reasoningWireFormat !== undefined + ? { reasoningWireFormat: registryEntry.reasoningWireFormat } + : {}), + ...(provider.defaultMaxOutputTokens === undefined && registryEntry.defaultMaxOutputTokens !== undefined + ? { defaultMaxOutputTokens: registryEntry.defaultMaxOutputTokens } + : {}), + ...(modelContextWindows ? { modelContextWindows } : {}), + ...(modelInputModalities ? { modelInputModalities } : {}), + ...(modelMaxInputTokens ? { modelMaxInputTokens } : {}), + ...(modelMaxOutputTokens ? { modelMaxOutputTokens } : {}), + ...(modelSupportsServiceTier ? { modelSupportsServiceTier } : {}), + ...(modelSupportsVerbosity ? { modelSupportsVerbosity } : {}), + ...(modelReasoningEfforts ? { modelReasoningEfforts } : {}), + ...(modelDefaultReasoningEfforts ? { modelDefaultReasoningEfforts } : {}), + ...(reasoningEffortMap ? { reasoningEffortMap } : {}), + ...(modelReasoningEffortMap ? { modelReasoningEffortMap } : {}), + ...(noVisionModels ? { noVisionModels } : {}), + ...(noReasoningModels ? { noReasoningModels } : {}), + ...(noTemperatureModels ? { noTemperatureModels } : {}), + ...(noTopPModels ? { noTopPModels } : {}), + ...(noStopModels ? { noStopModels } : {}), + ...(noPenaltyModels ? { noPenaltyModels } : {}), + ...(noJsonSchemaModels ? { noJsonSchemaModels } : {}), + ...(autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels } : {}), + ...(preserveReasoningContentModels ? { preserveReasoningContentModels } : {}), + ...(requiresReasoningPlaceholderModels ? { requiresReasoningPlaceholderModels } : {}), + ...(reasoningSplitModels ? { reasoningSplitModels } : {}), + ...(inlineThinkTagModels ? { inlineThinkTagModels } : {}), + ...(reasoningDetailsModels ? { reasoningDetailsModels } : {}), + ...(thinkingToggleModels ? { thinkingToggleModels } : {}), + ...(thinkingBudgetModels ? { thinkingBudgetModels } : {}), + }; + applyDirectReasoningEffortContracts(registryEntry, resolved, provider); + return resolved; +} diff --git a/src/router.ts b/src/router.ts index 942d0a8806e..e495ad79a61 100644 --- a/src/router.ts +++ b/src/router.ts @@ -14,26 +14,16 @@ import { import type { NormalizedComboConfig } from "./combos/types"; import { hasOwnProvider } from "./config/provider-name"; import { anthropicInstanceRowShapeMatches } from "./providers/anthropic-instance-id"; -import { providerUsesKeyAuthOverride, resolveProviderApiKey } from "./providers/key-store"; -import { GCP_CREDENTIAL_MARKER_PREFIX } from "./lib/gcp-adc"; -import { captureProviderApiKeySelection } from "./providers/api-key-selection-capture"; +import { reconcileRouterWarningMemos, routedProviderConfig } from "./providers/routed-config"; import { assertProviderDestinationAllowed } from "./lib/destination-policy"; -import { redactSecretString, redactUrlForLog } from "./lib/redact"; import { PROVIDER_REGISTRY, providerCodexAccountMode, - registryEntryForProviderDestination, } from "./providers/registry"; // Imported from the module directly rather than through the registry facade, which is at its // file-size cap. import { registryModelIdKeys } from "./providers/registry/model-ids"; -import { applyDirectReasoningEffortContracts, hasLegacyClinePassReasoningEfforts } from "./providers/derive"; -import { cloneFastWire } from "./providers/fastwire"; -import { fastSwitchOff } from "./providers/fast-opt-in"; -import { - providerMatchesRegistryTransportWithStaticGuards, - providerSupportsLiveModelDiscovery, -} from "./providers/static-model-discovery"; +import { providerMatchesRegistryTransportWithStaticGuards } from "./providers/static-model-discovery"; import { isCanonicalOpenAiForwardProvider, LEGACY_CHATGPT_PROVIDER_ID, @@ -203,82 +193,6 @@ export function knownModelIdsForProvider( return [...ids]; } -/** Same endpoint modulo surrounding space and trailing slashes — matches `matchBaseUrlChoice`. */ -function isSameEndpoint(a: string, b: string): boolean { - return a.trim().replace(/\/+$/, "") === b.trim().replace(/\/+$/, ""); -} - -/** - * Origin of a user-configured URL, with the path withheld. - * - * A configured `baseUrl` is user-controlled and its path may itself be the credential — an - * account-scoped route token such as `https://proxy.example/v1/8fK2mP7qR4nV6x` is opaque and - * high-entropy, so it matches none of the prefix patterns in `redactSecretString`. Pattern - * redaction cannot be trusted for this value, so no path segment is logged at all. `URL.origin` - * also excludes userinfo, query and fragment. - * - * `…/…` marks that a path was present without revealing it, so a reader can tell an origin-only - * config apart from one whose path was dropped. - */ -function configuredOriginForLog(url: string): string { - try { - const parsed = new URL(url.trim()); - // "null" is what URL.origin yields for non-special schemes; treat it as unusable. - if (!parsed.origin || parsed.origin === "null") return "(unloggable URL)"; - const hasPath = parsed.pathname !== "" && parsed.pathname !== "/"; - return hasPath ? `${parsed.origin}/…` : parsed.origin; - } catch { - return "(unparseable URL)"; - } -} - -// `routedProviderConfig` runs per request, so warn once per (provider, discarded, effective) triple. -// Keyed by the URLs too: editing config.json to a different wrong value warns again. -const discardedBaseUrlWarnings = new Set(); -let lastWarningReconciledGeneration = 0; - -export function reconcileRouterWarningMemos(generation: number): number { - if (generation <= lastWarningReconciledGeneration) return 0; - const removed = discardedBaseUrlWarnings.size; - discardedBaseUrlWarnings.clear(); - lastWarningReconciledGeneration = generation; - return removed; -} - -/** - * A pinned registry entry — non-template `baseUrl`, no `allowBaseUrlOverride` — outranks a saved - * `baseUrl`. Dropping it silently is a footgun: requests go to an endpoint the user never - * configured, and a wrong-region or wrong-account URL then surfaces only as a 401 with nothing - * pointing back at the discarded setting. - * - * Warns rather than throws. The effective route is exactly what it was before, so a hard error - * here would break configs that route fine today (a stale `baseUrl` left over from an earlier - * provider is harmless whenever it names the same endpoint the registry pins). - */ -function warnIfBaseUrlDiscarded(providerName: string, userBaseUrl: string, effectiveBaseUrl: string): void { - if (isSameEndpoint(userBaseUrl, effectiveBaseUrl)) return; - // Asymmetric on purpose. Past the guard above, `effectiveBaseUrl` is necessarily - // `registryEntry.baseUrl`: the caller passes the resolved URL, and whenever that resolution - // kept the user's value the two are equal and we have already returned. So the effective side - // is a constant from this repo's registry and safe to print in full — it is also the useful - // half, naming the endpoint requests will actually use. The configured side is untrusted. - const discarded = configuredOriginForLog(userBaseUrl); - const effective = redactSecretString(redactUrlForLog(effectiveBaseUrl)); - // Key off the logged forms: no raw credential is retained for the process lifetime, and - // rotating a key embedded in the URL no longer re-warns about the same endpoint mismatch. - // Coarser than the raw URLs — two bad paths on one host warn once, which is the right grain. - const key = `${providerName} | ${discarded} | ${effective}`; - if (discardedBaseUrlWarnings.has(key)) return; - discardedBaseUrlWarnings.add(key); - console.warn( - // Routing is what this warning speaks for: an adapter may adjust the endpoint again - // downstream (kiro re-derives the region), so do not promise where the request lands. - `⚠️ config.json provider "${providerName}": configured baseUrl ${discarded} is ignored` - + ` because this provider's endpoint is fixed at ${effective}. A URL saved for a different` - + ` account or region is a common cause of 401s here — drop it, or use the provider whose endpoint matches.`, - ); -} - /** * One notice per provider id: the destination is an operator-configured key * (never a caller-supplied string), so the log carries no request content. @@ -298,245 +212,10 @@ export function resetCompactionFallbackWarningsForTests(): void { compactionFallbackWarnings.clear(); } -function usableResolvedApiKey(apiKey: string | undefined): string | undefined { - // A `gcp-sa:` marker must pass through UNCHANGED: it resolves to `undefined` here (it is not a - // keychain/env ref, and the marker itself is not a literal), and replacing it would silently - // drop the marker from the routed config — the vertex adapter would then read - // `gcpCredentialMarkerAccount(undefined)` and fall back to host ADC instead of the configured - // service account. Return the marker verbatim so the adapter passes its account per request. - if (typeof apiKey === "string" && apiKey.startsWith(GCP_CREDENTIAL_MARKER_PREFIX)) return apiKey; - const resolved = resolveProviderApiKey(apiKey); - return typeof resolved === "string" && resolved.trim().length > 0 ? resolved : undefined; -} - -export function routedProviderConfig(providerName: string, provider: OcxProviderConfig): OcxProviderConfig { - provider = { ...provider, _apiKeyAttempt: provider._apiKeyAttempt ?? captureProviderApiKeySelection(provider) }; - const registryEntry = PROVIDER_REGISTRY.find(entry => entry.id === providerName); - if (!registryEntry || !providerMatchesRegistryTransportWithStaticGuards(providerName, provider)) { - assertProviderDestinationAllowed(providerName, provider); - // A row whose adapter no longer matches its registry entry still reaches the Responses - // adapter when one model opts in through `modelAdapters` — a Volcengine Coding Plan config - // saved on Chat, for instance. The replay-drop flag belongs to the DESTINATION rather than - // to the provider-wide wire, so it is filled here too; without it that continuation forwards - // 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); - return { - ...provider, - apiKey: usableResolvedApiKey(provider.apiKey), - ...(provider.dropResponsesReasoningItems === undefined && destination?.dropResponsesReasoningItems !== undefined - ? { dropResponsesReasoningItems: destination.dropResponsesReasoningItems } - : {}), - }; - } - const resolvedApiKey = usableResolvedApiKey(provider.apiKey); - const staticModelCatalog = !providerSupportsLiveModelDiscovery(providerName, provider); - const repairLegacyMimoFreeAuth = providerName === "mimo-free" - && staticModelCatalog - && (provider.authMode === undefined || provider.authMode === "local"); - const explicitKeyOverride = providerUsesKeyAuthOverride(registryEntry, provider, resolvedApiKey); - const canonicalAuthMode = explicitKeyOverride - ? "key" - : repairLegacyMimoFreeAuth - ? "key" - : registryEntry.authKind === "forward" || registryEntry.authKind === "oauth" - ? registryEntry.authKind - : provider.authMode === "forward" ? undefined : provider.authMode; - const staticPolicy = resolveModelPolicy({ - providerName, - modelId: "__provider_static__", - provider, - registryEntry, - transportMatchedRegistry: true, - ...(canonicalAuthMode ? { effectiveAuth: { authMode: canonicalAuthMode } } : {}), - }).provider; - const reasoningEffortMap = staticPolicy.reasoningEffortMap; - const modelReasoningEffortMap = staticPolicy.modelReasoningEffortMap; - const modelReasoningEfforts = staticPolicy.modelReasoningEfforts; - const modelDefaultReasoningEfforts = staticPolicy.modelDefaultReasoningEfforts; - const modelContextWindows = staticPolicy.modelContextWindows; - const modelInputModalities = staticPolicy.modelInputModalities; - // Registry static headers are documented as applying to every upstream request, so they are - // filled at resolve time rather than only at seed time: a config written before a header - // existed, or one carrying any header of its own, would otherwise never receive it. User - // headers win, matched case-insensitively so an override replaces rather than duplicates. - const headers = staticPolicy.headers; - const modelMaxInputTokens = staticPolicy.modelMaxInputTokens; - const modelMaxOutputTokens = staticPolicy.modelMaxOutputTokens; - const modelSupportsServiceTier = staticPolicy.modelSupportsServiceTier; - const modelSupportsVerbosity = staticPolicy.modelSupportsVerbosity; - const noVisionModels = staticPolicy.noVisionModels; - const noReasoningModels = staticPolicy.noReasoningModels; - const noTemperatureModels = staticPolicy.noTemperatureModels; - const noTopPModels = staticPolicy.noTopPModels; - const noStopModels = staticPolicy.noStopModels; - const noPenaltyModels = staticPolicy.noPenaltyModels; - const noJsonSchemaModels = staticPolicy.noJsonSchemaModels; - const autoToolChoiceOnlyModels = staticPolicy.autoToolChoiceOnlyModels; - const preserveReasoningContentModels = staticPolicy.preserveReasoningContentModels; - const requiresReasoningPlaceholderModels = staticPolicy.requiresReasoningPlaceholderModels; - const reasoningSplitModels = staticPolicy.reasoningSplitModels; - const inlineThinkTagModels = staticPolicy.inlineThinkTagModels; - const reasoningDetailsModels = staticPolicy.reasoningDetailsModels; - const thinkingToggleModels = staticPolicy.thinkingToggleModels; - const thinkingBudgetModels = staticPolicy.thinkingBudgetModels; - const registryBaseUrlIsTemplate = /\{[^}]*\}/.test(registryEntry.baseUrl); - const userBaseUrl = typeof provider.baseUrl === "string" ? provider.baseUrl.trim() : ""; - const userBaseUrlIsResolved = userBaseUrl.length > 0 && !/\{[^}]*\}/.test(userBaseUrl); - if (registryEntry.allowBaseUrlOverride && !userBaseUrlIsResolved) { - throw new Error(`Invalid baseUrl for provider "${providerName}": expected a nonblank URL without unresolved placeholders`); - } - // Registry template URLs are presets; local/self-hosted entries opt in explicitly. - const baseUrl = (registryBaseUrlIsTemplate || registryEntry.allowBaseUrlOverride) && userBaseUrlIsResolved - ? userBaseUrl - : registryEntry.baseUrl; - if (userBaseUrlIsResolved) warnIfBaseUrlDiscarded(providerName, userBaseUrl, baseUrl); - assertProviderDestinationAllowed(providerName, { baseUrl, allowPrivateNetwork: provider.allowPrivateNetwork }); - - const resolved: OcxProviderConfig = { - ...provider, - adapter: registryEntry.adapter, - baseUrl, - ...(provider.responsesPath === undefined && registryEntry.responsesPath !== undefined - ? { responsesPath: registryEntry.responsesPath } - : {}), - ...(provider.chatCompletionsPath === undefined && registryEntry.chatCompletionsPath !== undefined - ? { chatCompletionsPath: registryEntry.chatCompletionsPath } - : {}), - ...(provider.requiresAdjacentResponsesToolResults === undefined - && registryEntry.requiresAdjacentResponsesToolResults !== undefined - ? { requiresAdjacentResponsesToolResults: registryEntry.requiresAdjacentResponsesToolResults } - : {}), - ...(provider.requiresPairedResponsesToolResults === undefined - && registryEntry.requiresPairedResponsesToolResults !== undefined - ? { requiresPairedResponsesToolResults: registryEntry.requiresPairedResponsesToolResults } - : {}), - ...(provider.annotateEmptyToolOutputs === undefined - && registryEntry.annotateEmptyToolOutputs !== undefined - ? { annotateEmptyToolOutputs: registryEntry.annotateEmptyToolOutputs } - : {}), - ...(provider.fastWire === undefined && registryEntry.fastWire !== undefined - ? { - fastWire: cloneFastWire(registryEntry.fastWire), - } - : {}), - ...(provider.supportsServiceTier === undefined && registryEntry.supportsServiceTier !== undefined - ? { supportsServiceTier: registryEntry.supportsServiceTier } - : {}), - // An off Fast switch is a provider-wide denial on the runtime provider, so a Fast policy - // resolved without the provider name still refuses (providerFastSwitchOff). - ...(fastSwitchOff(provider, registryEntry) ? { supportsServiceTier: false } : {}), - // Registry-only web-search capability: without this backfill a saved provider row reaches - // the Responses adapter with the flag `undefined`, so the capability gate added in #2262 - // reads "unclassified" and forwards Codex's OpenAI-only `web_search` config fields. xAI - // rejects the whole request before inference ("Argument not supported: - // external_web_access"), which killed every routed Grok turn on the Responses lane. - // enrichProviderFromRegistry() already fills this, but the request path resolves through - // routedProviderConfig() and never called it. - ...(provider.supportsOpenAiWebSearchToolFields === undefined - && registryEntry.supportsOpenAiWebSearchToolFields !== undefined - ? { supportsOpenAiWebSearchToolFields: registryEntry.supportsOpenAiWebSearchToolFields } - : {}), - ...(provider.supportsResponsesCustomTools === undefined && registryEntry.supportsResponsesCustomTools !== undefined - ? { supportsResponsesCustomTools: registryEntry.supportsResponsesCustomTools } - : {}), - ...(provider.preserveResponsesReasoningContent === undefined && registryEntry.preserveResponsesReasoningContent !== undefined - ? { preserveResponsesReasoningContent: registryEntry.preserveResponsesReasoningContent } - : {}), - ...(provider.preserveResponsesInputItemIds === undefined && registryEntry.preserveResponsesInputItemIds !== undefined - ? { preserveResponsesInputItemIds: registryEntry.preserveResponsesInputItemIds } - : {}), - ...(provider.preserveResponsesMessageMetadata === undefined && registryEntry.preserveResponsesMessageMetadata !== undefined - ? { preserveResponsesMessageMetadata: registryEntry.preserveResponsesMessageMetadata } - : {}), - ...(provider.dropResponsesReasoningItems === undefined && registryEntry.dropResponsesReasoningItems !== undefined - ? { dropResponsesReasoningItems: registryEntry.dropResponsesReasoningItems } - : {}), - // The request path resolves through routedProviderConfig() and never calls - // enrichProviderFromRegistry(), so a saved provider row written before the - // registry learned this flag must be backfilled here or route.provider never - // carries it and the showThinkingSummary opt-in stays dead. - ...(provider.showThinkingSummary === undefined && registryEntry.showThinkingSummary !== undefined - ? { showThinkingSummary: registryEntry.showThinkingSummary } - : {}), - // Registry-only client-facing repair policy (#938): fill only when the - // saved provider has no explicit policy; clone so runtime never aliases - // the registry constant. - ...(provider.responsesItemIdRepair === undefined && registryEntry.responsesItemIdRepair - ? { - responsesItemIdRepair: { - ...(registryEntry.responsesItemIdRepair.message ? { message: [...registryEntry.responsesItemIdRepair.message] } : {}), - ...(registryEntry.responsesItemIdRepair.reasoning ? { reasoning: [...registryEntry.responsesItemIdRepair.reasoning] } : {}), - ...(registryEntry.responsesItemIdRepair.repairMissingTerminalIds !== undefined - ? { repairMissingTerminalIds: registryEntry.responsesItemIdRepair.repairMissingTerminalIds } - : {}), - ...(registryEntry.responsesItemIdRepair.repairInvalidIds !== undefined - ? { repairInvalidIds: registryEntry.responsesItemIdRepair.repairInvalidIds } - : {}), - }, - } - : {}), - authMode: canonicalAuthMode, - apiKey: resolvedApiKey, - ...(staticModelCatalog ? { liveModels: false } : {}), - ...(headers ? { headers } : {}), - // Backfill the Google wire mode + Vertex project/location from the registry when the user - // config omits them, so a minimal `google-vertex`/`google-antigravity` entry still routes - // through the correct branch (CCA/Vertex) instead of falling back to AI Studio. - ...(provider.googleMode === undefined && registryEntry.googleMode !== undefined ? { googleMode: registryEntry.googleMode } : {}), - ...(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 } : {}), - ...((provider.reasoningEfforts === undefined || hasLegacyClinePassReasoningEfforts(providerName, provider)) - && registryEntry.reasoningEfforts !== undefined - ? { reasoningEfforts: [...registryEntry.reasoningEfforts] } - : {}), - ...(provider.escapeBuiltinToolNames === undefined && registryEntry.escapeBuiltinToolNames !== undefined ? { escapeBuiltinToolNames: registryEntry.escapeBuiltinToolNames } : {}), - ...(provider.keyOptional === undefined && registryEntry.keyOptional !== undefined ? { keyOptional: registryEntry.keyOptional } : {}), - ...(provider.modelSuffixBracketStrip === undefined && registryEntry.modelSuffixBracketStrip !== undefined ? { modelSuffixBracketStrip: registryEntry.modelSuffixBracketStrip } : {}), - // Scalar backfill: a persisted config created before the flag shipped inherits the registry - // opt-in, while an explicit user `false` keeps overriding registry `true`. - ...(provider.parallelToolCalls === undefined && registryEntry.parallelToolCalls !== undefined ? { parallelToolCalls: registryEntry.parallelToolCalls } : {}), - ...(provider.promptCacheKey === undefined && registryEntry.promptCacheKey !== undefined ? { promptCacheKey: registryEntry.promptCacheKey } : {}), - ...(provider.chatServiceTier === undefined && registryEntry.chatServiceTier !== undefined ? { chatServiceTier: registryEntry.chatServiceTier } : {}), - ...(provider.openaiChatEofTolerance === undefined && registryEntry.openaiChatEofTolerance !== undefined - ? { openaiChatEofTolerance: registryEntry.openaiChatEofTolerance } - : {}), - ...(provider.reasoningWireFormat === undefined && registryEntry.reasoningWireFormat !== undefined - ? { reasoningWireFormat: registryEntry.reasoningWireFormat } - : {}), - ...(provider.defaultMaxOutputTokens === undefined && registryEntry.defaultMaxOutputTokens !== undefined - ? { defaultMaxOutputTokens: registryEntry.defaultMaxOutputTokens } - : {}), - ...(modelContextWindows ? { modelContextWindows } : {}), - ...(modelInputModalities ? { modelInputModalities } : {}), - ...(modelMaxInputTokens ? { modelMaxInputTokens } : {}), - ...(modelMaxOutputTokens ? { modelMaxOutputTokens } : {}), - ...(modelSupportsServiceTier ? { modelSupportsServiceTier } : {}), - ...(modelSupportsVerbosity ? { modelSupportsVerbosity } : {}), - ...(modelReasoningEfforts ? { modelReasoningEfforts } : {}), - ...(modelDefaultReasoningEfforts ? { modelDefaultReasoningEfforts } : {}), - ...(reasoningEffortMap ? { reasoningEffortMap } : {}), - ...(modelReasoningEffortMap ? { modelReasoningEffortMap } : {}), - ...(noVisionModels ? { noVisionModels } : {}), - ...(noReasoningModels ? { noReasoningModels } : {}), - ...(noTemperatureModels ? { noTemperatureModels } : {}), - ...(noTopPModels ? { noTopPModels } : {}), - ...(noStopModels ? { noStopModels } : {}), - ...(noPenaltyModels ? { noPenaltyModels } : {}), - ...(noJsonSchemaModels ? { noJsonSchemaModels } : {}), - ...(autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels } : {}), - ...(preserveReasoningContentModels ? { preserveReasoningContentModels } : {}), - ...(requiresReasoningPlaceholderModels ? { requiresReasoningPlaceholderModels } : {}), - ...(reasoningSplitModels ? { reasoningSplitModels } : {}), - ...(inlineThinkTagModels ? { inlineThinkTagModels } : {}), - ...(reasoningDetailsModels ? { reasoningDetailsModels } : {}), - ...(thinkingToggleModels ? { thinkingToggleModels } : {}), - ...(thinkingBudgetModels ? { thinkingBudgetModels } : {}), - }; - applyDirectReasoningEffortContracts(registryEntry, resolved, provider); - return resolved; -} +// `routedProviderConfig` and its warning memo moved to `providers/routed-config.ts`: the +// request-time resolver needs no routing state, and `providers/api-key-selection.ts` importing +// it through this file created an import cycle (router -> quota -> api-keys -> api-key-selection). +export { reconcileRouterWarningMemos, routedProviderConfig } from "./providers/routed-config"; /** * Rows eligible for bare-model inference (configured default model, model list, model alias). diff --git a/src/routing/profile.ts b/src/routing/profile.ts index 6c978de94a6..5e892b40c17 100644 --- a/src/routing/profile.ts +++ b/src/routing/profile.ts @@ -12,7 +12,7 @@ import type { OcxRoutingUnknownCostCapMode, } from "../types"; import { codexAccountNamespaceEntries } from "../codex/account-namespaces"; -import { listComboIds, resolveComboId } from "../combos"; +import { resolveComboId } from "../combos/identifiers"; import { hasOwnProvider } from "../config/provider-name"; import { MAX_COMPATIBILITY_REQUIRED_SUITES } from "./compatibility/types"; import { POLICY_NAMESPACE } from "./profile-namespace"; diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 55cdbefb9d2..d450d49ba5d 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -15,10 +15,8 @@ import { } from "./admission-model-scope"; import { jsonUtf8Bytes } from "../lib/json-byte-size"; import { redactSecretString } from "../lib/redact"; -import { sseFieldValue } from "../lib/sse-decoder"; -import { enforceAnthropicImageLimits, sniffImageDimensions } from "../adapters/anthropic-image-guard"; +import { enforceAnthropicImageLimits } from "../adapters/anthropic-image-guard"; import { normalizeAnthropicImages } from "../adapters/anthropic-image-normalize"; -import { createToolCallIdAllocator } from "../adapters/tool-call-id"; import { openAIChatSerializesThinking } from "../adapters/openai-chat/messages"; import { messagesToResponsesTranslation } from "../protocols/codecs/messages"; import { @@ -60,10 +58,8 @@ import { responsesSseToAnthropicSse, } from "../claude/outbound"; import { clearableDeadline, idleDeadline } from "../lib/abort"; -import { estimateTokens } from "../lib/token-estimate"; import { CLAUDE_NATIVE_THINKING, - projectClaudeRequest, type ClaudeThinkingProjection, } from "../lib/claude-request-projection"; import { captureRouteStaticPolicy, NoEligiblePolicyCandidateError, previewRouteModel, routedProviderConfig, UnknownRoutingPolicyError, routeModel, type RouteResult } from "../router"; @@ -72,7 +68,6 @@ import { resolveWireProtocolOverride } from "./adapter-resolve"; import type { OcxConfig } from "../types"; import { readJsonRequestBody, resolveInboundBodyLimitBytes } from "./request-decompress"; import { addFinalRequestLog, httpStatusForRequestLogTerminal, recordFirstOutput, type RequestLogContext } from "./request-log"; -import { recordGenerationEvent } from "./request-log-generation-window"; import { createFinalRequestLog } from "./inference/final-log"; import { conversationIdFromClaudeMetadata, @@ -285,309 +280,39 @@ function uuidFromHex(hex32: string): string { return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-8${h.slice(17, 20)}-${h.slice(20, 32)}`; } -export function anthropicUsageToOcx(usage: Rec | undefined): { inputTokens: number; outputTokens: number; cachedInputTokens?: number; cacheReadInputTokens?: number; cacheCreationInputTokens?: number } | undefined { - if (!usage) return undefined; - const num = (v: unknown) => typeof v === "number" ? v : 0; - const hasCache = usage.cache_read_input_tokens !== undefined || usage.cache_creation_input_tokens !== undefined; - const read = num(usage.cache_read_input_tokens); - const write = num(usage.cache_creation_input_tokens); - // Anthropic input_tokens excludes cache read/write; normalize to the canonical - // inclusive convention (types.ts OcxUsage / devlog 070). cached = READS only. - return { - inputTokens: num(usage.input_tokens) + read + write, - outputTokens: num(usage.output_tokens), - ...(hasCache ? { - cachedInputTokens: read, - cacheReadInputTokens: read, - cacheCreationInputTokens: write, - } : {}), - }; -} - -/** Body-occupancy guard for the native passthrough (devlog 260716_passthrough_followups/010). */ -export interface PassthroughBodyGuard { - /** Idle window in ms — raw upstream-byte inactivity while a read is pending. 0 disables. */ - stallMs: number; - /** Cumulative body byte cap. 0 disables. */ - maxBytes: number; - /** Client request signal for deterministic cancel classification. */ - reqSignal?: AbortSignal; -} - -type PassthroughCloseReason = "terminal" | "client_cancel" | "body_stall" | "body_overflow"; -type PassthroughFinalizeMeta = { closeReason: PassthroughCloseReason; terminalStatus?: "failed" | "incomplete" }; - /** - * Tap an Anthropic-vocabulary SSE stream for the request log (usage + terminal), - * bounding body occupancy: idle (silence-only, timed ONLY while a reader.read() is - * pending so downstream backpressure never counts as upstream inactivity) and a - * cumulative byte cap. On stall/overflow, or when the upstream read fails after the - * headers went out, it appends a protocol-compatible Anthropic `event: error` terminal - * frame after a blank-line boundary, closes, and cancels the upstream reader — never a - * total-wall-clock bound (slow-but-alive streams live). - * Exported for deterministic unit tests. + * The passthrough accounting layer, shared with the managed native lane. It lives in + * messages-shared.ts so that lane can take it without importing this module, and this + * module without importing that lane. Re-exported here because this file was its only + * home and callers still resolve it from this module. */ -export function tapAnthropicSseForLog( - upstream: ReadableStream, - logCtx: RequestLogContext, - finalize: (status: number, meta: PassthroughFinalizeMeta) => void, - guard?: PassthroughBodyGuard, -): ReadableStream { - const decoder = new TextDecoder(); - const encoder = new TextEncoder(); - let buffer = ""; - let usageAcc: Rec = {}; - // message_stop or an upstream error event: the turn's own terminal already went through. - let terminalSeen = false; - const inspectFrame = (frame: string) => { - const dataLine = frame - .split("\n") - .map(l => sseFieldValue(l, "data")) - .filter((v): v is string => v !== null) - .join(""); - if (!dataLine) return; - let data: unknown; - try { data = JSON.parse(dataLine); } catch { return; } - if (!isRec(data)) return; - recordGenerationEvent(logCtx, data.type); - if (data.type === "message_start" && isRec(data.message) && isRec(data.message.usage)) { - usageAcc = { ...usageAcc, ...data.message.usage }; - } else if (data.type === "message_delta" && isRec(data.usage)) { - usageAcc = { ...usageAcc, ...data.usage }; - } else if (data.type === "message_stop" || data.type === "error") { - terminalSeen = true; - } - }; - const inspect = (chunk: Uint8Array) => { - buffer += decoder.decode(chunk, { stream: true }); - // SSE lines may end in CRLF, LF or CR. Normalize the inspection copy to LF (the forwarded - // bytes are untouched), holding a trailing CR until the next chunk shows whether an LF - // follows it, so a CRLF split across chunks stays one line ending. - const heldCr = buffer.endsWith("\r"); - buffer = (heldCr ? buffer.slice(0, -1) : buffer).replace(/\r\n?/g, "\n") + (heldCr ? "\r" : ""); - let sep: number; - while ((sep = buffer.indexOf("\n\n")) !== -1) { - const frame = buffer.slice(0, sep); - buffer = buffer.slice(sep + 2); - inspectFrame(frame); - } - }; - // The last block can sit in the buffer without its blank line: an upstream that stopped after - // it, or a held trailing CR. Count it before deciding how the turn ended (the Responses relay - // flushes the same candidate). Returns true when this flush found the terminal in a block the - // client has not seen a blank line after, so the caller restores one. - const flushTail = (): boolean => { - const terminalBefore = terminalSeen; - const tail = (buffer + decoder.decode()).replace(/\r\n?/g, "\n"); - buffer = ""; - if (tail) inspectFrame(tail); - return terminalSeen && !terminalBefore && !tail.endsWith("\n\n"); - }; - const reader = upstream.getReader(); - let settled = false; - let bodyBytes = 0; - let tapController: ReadableStreamDefaultController | undefined; +import { + anthropicUsageToOcx, + estimateClaudeRequestTokens, + resolvePassthroughBodyGuard, + sanitizePassthroughToolCallIds, + tapAnthropicSseForLog, + type PassthroughBodyGuard, + type PassthroughCloseReason, +} from "./messages-shared"; - const recordUsage = () => { - logCtx.usage = anthropicUsageToOcx(Object.keys(usageAcc).length > 0 ? usageAcc : undefined); - }; - const closeWithErrorFrame = (errType: string, message: string) => { - const payload = JSON.stringify({ type: "error", error: { type: errType, message } }); - try { - // Leading blank line terminates any partial SSE block so the frame parses cleanly - // (relaySseWithFailedTail policy, Anthropic wire shape). - tapController?.enqueue(encoder.encode(`\n\nevent: error\ndata: ${payload}\n\n`)); - tapController?.close(); - } catch { /* client already torn down */ } - }; - const failBody = (closeReason: "body_stall" | "body_overflow", errType: string, message: string) => { - if (settled) return; - settled = true; - idle.cancel(); - detachAbort(); - const terminalInTail = flushTail(); - recordUsage(); - if (terminalSeen) { - // The turn already ended (message_stop or an upstream error event): an upstream that then - // idles or keeps sending did not cut it short. Same rule as the read-error branch, - // including the restored blank line for a terminal found only in the tail. - finalize(200, { closeReason: "terminal" }); - try { - if (terminalInTail) tapController?.enqueue(encoder.encode("\n\n")); - tapController?.close(); - } catch { /* client already torn down */ } - } else { - // A cut-short turn, logged as the Responses relay logs a stall-timeout incomplete - // (httpStatusForRequestLogTerminal: only a max_output_tokens incomplete is a 200). A 200 - // row with no terminalStatus also lost its failure diagnostics in usage.jsonl. - logCtx.upstreamError = message.slice(0, 500); - finalize(502, { terminalStatus: "incomplete", closeReason }); - closeWithErrorFrame(errType, message); - } - reader.cancel(new DOMException(message, closeReason === "body_stall" ? "TimeoutError" : "QuotaExceededError")).catch(() => {}); - }; - const idle = idleDeadline(guard?.stallMs ?? 0, () => { - failBody( - "body_stall", - "timeout_error", - `anthropic passthrough body stalled: no upstream bytes for ${Math.round((guard?.stallMs ?? 0) / 1000)}s`, - ); - }); - // Deterministic client-cancel classification: Bun may surface a client abort as a - // reader.read() rejection OR a resolved done (src/lib/abort.ts cancelBodyOnAbort - // rationale), so the listener performs first-wins settlement itself instead of - // relying on which shape the read takes. - const onClientAbort = () => { - if (settled) return; - settled = true; - idle.cancel(); - detachAbort(); - finalize(499, { closeReason: "client_cancel" }); - try { tapController?.close(); } catch { /* downstream already torn down */ } - reader.cancel(guard?.reqSignal?.reason).catch(() => {}); - }; - const detachAbort = (() => { - const signal = guard?.reqSignal; - if (!signal) return () => {}; - if (signal.aborted) { - queueMicrotask(onClientAbort); - return () => {}; - } - signal.addEventListener("abort", onClientAbort, { once: true }); - return () => signal.removeEventListener("abort", onClientAbort); - })(); +export { + anthropicUsageToOcx, + estimateClaudeRequestTokens, + resolvePassthroughBodyGuard, + sanitizePassthroughToolCallIds, + tapAnthropicSseForLog, + type PassthroughBodyGuard, + type PassthroughCloseReason, + type PassthroughFinalizeMeta, +} from "./messages-shared"; - return new ReadableStream({ - start(controller) { - tapController = controller; - }, - async pull(controller) { - if (settled) return; - try { - idle.reset(); - const { done, value } = await reader.read(); - idle.pause(); - if (settled) return; // stall/overflow/abort won the race while we awaited - if (done) { - settled = true; - idle.cancel(); - detachAbort(); - recordUsage(); - finalize(200, { closeReason: "terminal" }); - controller.close(); - return; - } - if (value.byteLength > 0) { - bodyBytes += value.byteLength; - if (guard && guard.maxBytes > 0 && bodyBytes > guard.maxBytes) { - failBody( - "body_overflow", - "api_error", - `anthropic passthrough body exceeded ${guard.maxBytes} bytes`, - ); - return; - } - } - inspect(value); - controller.enqueue(value); - } catch (err) { - if (settled) return; - // Bun can settle a fetch body read before it dispatches the abort listeners - // (consumeForInspection in relay.ts): read the signal itself before calling this - // rejection an upstream failure. - if (guard?.reqSignal?.aborted) { - onClientAbort(); - return; - } - settled = true; - idle.cancel(); - detachAbort(); - const terminalInTail = flushTail(); - recordUsage(); - if (isTranslatorBudgetExceededError(err)) { - // A local cap, not an upstream failure: the non-streaming native Messages fold - // maps this error to a 413 itself, so it still errors the stream. - finalize(200, { closeReason: "terminal" }); - try { controller.error(err); } catch { /* torn down */ } - return; - } - if (terminalSeen) { - // Only the transport trailer was lost; the client already has the turn's terminal. - // The Responses relay likewise reports a read error only without a seen terminal. - finalize(200, { closeReason: "terminal" }); - try { - // An SSE parser drops an event that EOF cuts off before its blank line, so restore - // the delimiter when the terminal was only found in that unterminated tail. - if (terminalInTail) controller.enqueue(encoder.encode("\n\n")); - controller.close(); - } catch { /* torn down */ } - reader.cancel(err).catch(() => {}); - return; - } - // The upstream read failed after the 200 went out (a mid-stream socket reset). Log it - // the way the Responses relay does (onReadError): a truncated body is a failed turn, - // not a completed one. The client gets the same Anthropic error terminal as a stall, - // instead of a connection reset — or, on some Bun releases, a bare EOF that reads as - // a finished message. - const message = redactSecretString(`anthropic passthrough upstream stream failed: ${err instanceof Error ? err.message : String(err)}`); - logCtx.transportPhase = "mid_stream"; - logCtx.terminalSource = "synthetic"; - logCtx.upstreamError = message.slice(0, 500); - if (logCtx.activeAttempt) logCtx.activeAttempt.streamAborted = true; - finalize(502, { terminalStatus: "failed", closeReason: "terminal" }); - closeWithErrorFrame("api_error", message); - reader.cancel(err).catch(() => {}); - } - }, - cancel(reason) { - if (!settled) { - settled = true; - idle.cancel(); - detachAbort(); - finalize(499, { closeReason: "client_cancel" }); - } - reader.cancel(reason).catch(() => {}); - }, - }); -} - -/** - * `tool_use.id` / `tool_result.tool_use_id` must match Anthropic's wire contract - * (`^[a-zA-Z0-9_-]+$`, <=64 chars). Third-party models mint other shapes — Devin's - * swe-2 emits `Bash:0#` — and a session history carrying them 400s the moment it - * is switched to a native Anthropic model ("messages.N.content.M.tool_use.id: String - * should match pattern"). The adapter path normalizes these via - * adapters/tool-call-id.ts (#1780); this passthrough bypasses that adapter, so the same - * allocator runs here. Stateless per request: conforming ids pass through byte-identical - * (prompt-cache keys untouched), rewritten ids keep call/result pairing stable. - * An empty id has no representable wire form, and forwarding `""` is what Anthropic - * rejects (#1767), so the request fails locally with a 400 before any upstream fetch. - */ -export function sanitizePassthroughToolCallIds(messages: unknown[]): void { - const blocks: Rec[] = []; - for (const message of messages) { - if (!isRec(message) || !Array.isArray(message.content)) continue; - for (const block of message.content) if (isRec(block)) blocks.push(block); - } - const fieldOf = (block: Rec): "id" | "tool_use_id" | undefined => { - if (typeof block.type !== "string") return undefined; - if (block.type.endsWith("tool_use")) return "id"; - if (block.type.endsWith("tool_result")) return "tool_use_id"; - return undefined; - }; - const callIds = createToolCallIdAllocator(); - for (const block of blocks) { - const field = fieldOf(block); - if (field && typeof block[field] === "string") callIds.reserve(block[field] as string); - } - for (const block of blocks) { - const field = fieldOf(block); - if (!field || typeof block[field] !== "string") continue; - const wire = callIds.allocate(block[field] as string); - if (wire === undefined) throw new AnthropicRequestError(`${block.type} block has an empty ${field}`); - block[field] = wire; - } -} +/** Upstream error types a non-stream passthrough failure may report in history. */ +const PASSTHROUGH_ERROR_TYPES = [ + "invalid_request_error", "authentication_error", "permission_error", "not_found_error", + "rate_limit_error", "api_error", "overloaded_error", "request_too_large", +] as const; +const PASSTHROUGH_ERROR_DETAIL_MAX_CHARS = 64 * 1024; async function anthropicNativePassthrough( req: Request, @@ -703,35 +428,6 @@ async function anthropicNativePassthrough( }); } -const PASSTHROUGH_ERROR_TYPES = [ - "invalid_request_error", "authentication_error", "permission_error", "not_found_error", - "rate_limit_error", "api_error", "overloaded_error", "request_too_large", -] as const; -const PASSTHROUGH_ERROR_DETAIL_MAX_CHARS = 64 * 1024; -const DEFAULT_BODY_STALL_SEC = 90; -const DEFAULT_BODY_MAX_BYTES = 64 * 1024 * 1024; - -/** - * Normalize the claudeCode body-guard config (devlog 260716_passthrough_followups/010). - * Policy: exactly 0 disables; finite positive values are honored (stall clamped to - * min 1s); negative/non-finite/absent values fall back to the defaults. - */ -export function resolvePassthroughBodyGuard(config: OcxConfig, reqSignal?: AbortSignal): PassthroughBodyGuard { - const rawSec = config.claudeCode?.bodyStallSec; - const stallSec = rawSec === 0 - ? 0 - : typeof rawSec === "number" && Number.isFinite(rawSec) && rawSec > 0 - ? Math.max(1, rawSec) - : DEFAULT_BODY_STALL_SEC; - const rawBytes = config.claudeCode?.bodyMaxBytes; - const maxBytes = rawBytes === 0 - ? 0 - : typeof rawBytes === "number" && Number.isFinite(rawBytes) && rawBytes > 0 - ? Math.floor(rawBytes) - : DEFAULT_BODY_MAX_BYTES; - return { stallMs: stallSec * 1000, maxBytes, ...(reqSignal ? { reqSignal } : {}) }; -} - type BoundedPassthroughBody = | { kind: "ok"; text: string } | { kind: "stall" } @@ -1641,79 +1337,6 @@ async function handleClaudeMessagesWithBudget( }); } -/** Per-attachment token estimate for a base64 payload: real image dimensions when the - * header is sniffable (Anthropic prices images at ~pixels/750), else decoded bytes/512, - * min 256 — the same shape as the Kiro usage estimator (estimateKiroImageTokens). */ -function estimateBase64AttachmentTokens(data: string): number { - const dims = sniffImageDimensions(data); - if (dims) return Math.max(256, Math.ceil((dims.width * dims.height) / 750)); - const unpadded = data.endsWith("==") ? data.length - 2 : data.endsWith("=") ? data.length - 1 : data.length; - return Math.max(256, Math.ceil(Math.floor((unpadded * 3) / 4) / 512)); -} - -/** - * Char-based token estimate for an Anthropic-shaped request body. Base64 attachment - * payloads (image/document blocks in message content, including blocks nested in - * tool_result.content) are counted as a bounded per-attachment estimate instead of raw - * characters: one 2MB screenshot is ~2.7M base64 chars, which the plain chars/token - * divide reports as hundreds of thousands of tokens versus a real cost around 1.6k. - * That breaks the >2x drift bound the estimator is held to (devlog 260711_claude_inbound - * 040 §3); a live 260-message turn whose replayed thinking was 78.8% of the body breached - * it at 3.28x, which is why the estimate is projected onto the settled route. Text and url - * sources are left in place and counted as characters, as is - * anything outside protocol content positions (tool_use.input, tool schemas). - * - * `thinking` selects which replayed thinking fields the SETTLED route serializes, so the measure - * describes the prompt this proxy forwards rather than the one the caller typed. Omitted, the - * whole body counts — correct for the Anthropic-native wire, where nothing is projected away. - * See `claude-request-projection.ts` for why a routed wire must project it out. - */ -export function estimateClaudeRequestTokens( - raw: { system?: unknown; messages?: unknown; tools?: unknown }, - modelId: string | undefined, - thinking: ClaudeThinkingProjection = CLAUDE_NATIVE_THINKING, -): number { - let attachmentTokens = 0; - // Blank base64 payloads ONLY in protocol content positions: message content blocks and - // blocks nested in tool_result.content. tool_use.input and tool schemas can legitimately - // contain attachment-shaped JSON, and those bytes ARE serialized into function_call - // arguments / tool definitions for routed providers, so they must keep counting as text. - // system is text-only per the Anthropic protocol (no attachment sources), so it is - // stringified as-is. - const sanitizeBlock = (block: unknown): unknown => { - if (!block || typeof block !== "object") return block; - const b = block as Record; - if (b.type === "image" || b.type === "document") { - const source = b.source as { type?: unknown; data?: unknown } | undefined; - if (source && typeof source === "object" && source.type === "base64" && typeof source.data === "string") { - attachmentTokens += estimateBase64AttachmentTokens(source.data); - return { ...b, source: { ...(source as Record), data: "" } }; - } - return block; - } - if (b.type === "tool_result" && Array.isArray(b.content)) { - return { ...b, content: (b.content as unknown[]).map(sanitizeBlock) }; - } - return block; - }; - const sanitizedMessages = (messages: unknown): unknown => - Array.isArray(messages) - ? messages.map(message => { - if (!message || typeof message !== "object") return message; - const m = message as Record; - return Array.isArray(m.content) ? { ...m, content: (m.content as unknown[]).map(sanitizeBlock) } : message; - }) - : messages; - const parts: string[] = []; - if (raw.system !== undefined) parts.push(typeof raw.system === "string" ? raw.system : JSON.stringify(raw.system)); - if (raw.messages !== undefined) { - const projected = projectClaudeRequest(raw, thinking); - parts.push(JSON.stringify(sanitizedMessages(projected.messages))); - } - if (raw.tools !== undefined) parts.push(JSON.stringify(raw.tools)); - return Math.max(1, estimateTokens(parts.join("\n"), modelId) + attachmentTokens); -} - /** * The projection for a route that has already settled. * diff --git a/src/server/effort-policy.ts b/src/server/effort-policy.ts index 5a8b63af7a3..16db1cf3471 100644 --- a/src/server/effort-policy.ts +++ b/src/server/effort-policy.ts @@ -15,7 +15,7 @@ import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../types"; import { modelInList } from "../types"; import { codexEffortRank, configuredReasoningEfforts, isCodexReasoningEffort, isDeclaredReasoningEffort, modelRecordValue } from "../reasoning-effort"; -import { catalogModelEfforts } from "../codex/catalog"; +import { catalogModelEfforts } from "../codex/catalog/effort"; /** * True when the request carries codex-rs's spawned-child markers, matched EXACTLY. diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index e03a008b200..91d51e128fd 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -271,7 +271,6 @@ export async function syncEnabledClientIntegrations( try { const { writeDesktop3pConfig } = await import("../../claude/desktop-3p"); const { desktopVisibleNativeSlugs, filterCatalogVisibleModels } = await import("../../codex/catalog"); - const { fetchAllModels } = await import("../management-api"); const models = await (deps.fetchAllModels ?? fetchAllModels)(config); // Serialized with Desktop mode transitions (picker lock): a first-party switch cannot interleave. const { runPickerTransition } = await import("./claude-desktop-picker-routes"); diff --git a/src/server/management/cursor-integration-routes.ts b/src/server/management/cursor-integration-routes.ts index ff694c4d6f9..cf356b4c119 100644 --- a/src/server/management/cursor-integration-routes.ts +++ b/src/server/management/cursor-integration-routes.ts @@ -16,7 +16,7 @@ import { buildCursorLocalInstallerHint, type CursorLocalInstallerHint } from ".. import { loadCursorEffortTable } from "../../integrations/cursor-effort-table"; import { configuredApiAuthToken, isApiAuthRequired, jsonResponse } from "../auth-cors"; import { localInferenceDestination } from "../../lib/local-destinations"; -import { fetchAllModels } from "../management-api"; +import { fetchAllModels } from "./shared"; import { predictCursorEffort } from "../models-capabilities"; import { expandCursorEffortRow, knownEffortRowIds } from "../effort-row"; import type { ManagementContext } from "./context"; diff --git a/src/server/management/native-integration-routes.ts b/src/server/management/native-integration-routes.ts index f2a0b13f494..4e752ceb393 100644 --- a/src/server/management/native-integration-routes.ts +++ b/src/server/management/native-integration-routes.ts @@ -289,13 +289,8 @@ function isConfigLockError(error: unknown): boolean { */ let grokToggleFlight: Promise | null = null; -/** - * Dynamic import, never static: management-api.ts statically imports THIS - * module, so a static import of fetchAllModels back from it would close a - * cycle. sync.ts:18-21 dodges the same cycle the same way. - */ async function defaultFetchAllModels(config: OcxConfig) { - const { fetchAllModels } = await import("../management-api"); + const { fetchAllModels } = await import("./shared"); return fetchAllModels(config); } diff --git a/src/server/messages-native.ts b/src/server/messages-native.ts index 2b9dfcbeacb..d61fdf56954 100644 --- a/src/server/messages-native.ts +++ b/src/server/messages-native.ts @@ -93,7 +93,7 @@ import { resolvePassthroughBodyGuard, sanitizePassthroughToolCallIds, tapAnthropicSseForLog, -} from "./claude-messages"; +} from "./messages-shared"; import { beginInferenceAttempt } from "./inference/attempt"; import { createFinalRequestLog, type FinalRequestLogMeta } from "./inference/final-log"; import { registerTurn, unregisterTurn } from "./lifecycle"; diff --git a/src/server/messages-shared.ts b/src/server/messages-shared.ts new file mode 100644 index 00000000000..05cd665d8d0 --- /dev/null +++ b/src/server/messages-shared.ts @@ -0,0 +1,431 @@ +/** + * Accounting and measurement pieces shared by the two Claude Messages lanes. + * + * claude-messages.ts (caller-credential passthrough and translate-and-replay) and + * messages-native.ts (the proxy-managed native lane) both need this layer, and + * neither may import the other. It once closed a cycle: messages-native.ts took this + * code statically from claude-messages.ts while claude-messages.ts reached back + * dynamically to dispatch, so every install that only serves caller-forward + * passthrough still loaded the whole native lane. + * + * Everything here is a leaf: lib/, adapters/ primitives, and types only. Nothing + * reaches another server/ module, so either lane can take it without pulling in the + * other. + */ +import { sniffImageDimensions } from "../adapters/anthropic-image-guard"; +import { createToolCallIdAllocator } from "../adapters/tool-call-id"; +import { idleDeadline } from "../lib/abort"; +import { + CLAUDE_NATIVE_THINKING, + projectClaudeRequest, + type ClaudeThinkingProjection, +} from "../lib/claude-request-projection"; +import { redactSecretString } from "../lib/redact"; +import { sseFieldValue } from "../lib/sse-decoder"; +import { estimateTokens } from "../lib/token-estimate"; +import { isTranslatorBudgetExceededError } from "../lib/translator-budget"; +import { AnthropicRequestError, isRec, type Rec } from "../claude/inbound-records"; +import type { RequestLogContext } from "./request-log"; +import { recordGenerationEvent } from "./request-log-generation-window"; +import type { OcxConfig } from "../types"; + +export function anthropicUsageToOcx(usage: Rec | undefined): { inputTokens: number; outputTokens: number; cachedInputTokens?: number; cacheReadInputTokens?: number; cacheCreationInputTokens?: number } | undefined { + if (!usage) return undefined; + const num = (v: unknown) => typeof v === "number" ? v : 0; + const hasCache = usage.cache_read_input_tokens !== undefined || usage.cache_creation_input_tokens !== undefined; + const read = num(usage.cache_read_input_tokens); + const write = num(usage.cache_creation_input_tokens); + // Anthropic input_tokens excludes cache read/write; normalize to the canonical + // inclusive convention (types.ts OcxUsage / devlog 070). cached = READS only. + return { + inputTokens: num(usage.input_tokens) + read + write, + outputTokens: num(usage.output_tokens), + ...(hasCache ? { + cachedInputTokens: read, + cacheReadInputTokens: read, + cacheCreationInputTokens: write, + } : {}), + }; +} + +/** Body-occupancy guard for the native passthrough (devlog 260716_passthrough_followups/010). */ +export interface PassthroughBodyGuard { + /** Idle window in ms — raw upstream-byte inactivity while a read is pending. 0 disables. */ + stallMs: number; + /** Cumulative body byte cap. 0 disables. */ + maxBytes: number; + /** Client request signal for deterministic cancel classification. */ + reqSignal?: AbortSignal; +} + +export type PassthroughCloseReason = "terminal" | "client_cancel" | "body_stall" | "body_overflow"; +export type PassthroughFinalizeMeta = { closeReason: PassthroughCloseReason; terminalStatus?: "failed" | "incomplete" }; + +/** + * Tap an Anthropic-vocabulary SSE stream for the request log (usage + terminal), + * bounding body occupancy: idle (silence-only, timed ONLY while a reader.read() is + * pending so downstream backpressure never counts as upstream inactivity) and a + * cumulative byte cap. On stall/overflow, or when the upstream read fails after the + * headers went out, it appends a protocol-compatible Anthropic `event: error` terminal + * frame after a blank-line boundary, closes, and cancels the upstream reader — never a + * total-wall-clock bound (slow-but-alive streams live). + * Exported for deterministic unit tests. + */ +export function tapAnthropicSseForLog( + upstream: ReadableStream, + logCtx: RequestLogContext, + finalize: (status: number, meta: PassthroughFinalizeMeta) => void, + guard?: PassthroughBodyGuard, +): ReadableStream { + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + let buffer = ""; + let usageAcc: Rec = {}; + // message_stop or an upstream error event: the turn's own terminal already went through. + let terminalSeen = false; + const inspectFrame = (frame: string) => { + const dataLine = frame + .split("\n") + .map(l => sseFieldValue(l, "data")) + .filter((v): v is string => v !== null) + .join(""); + if (!dataLine) return; + let data: unknown; + try { data = JSON.parse(dataLine); } catch { return; } + if (!isRec(data)) return; + recordGenerationEvent(logCtx, data.type); + if (data.type === "message_start" && isRec(data.message) && isRec(data.message.usage)) { + usageAcc = { ...usageAcc, ...data.message.usage }; + } else if (data.type === "message_delta" && isRec(data.usage)) { + usageAcc = { ...usageAcc, ...data.usage }; + } else if (data.type === "message_stop" || data.type === "error") { + terminalSeen = true; + } + }; + const inspect = (chunk: Uint8Array) => { + buffer += decoder.decode(chunk, { stream: true }); + // SSE lines may end in CRLF, LF or CR. Normalize the inspection copy to LF (the forwarded + // bytes are untouched), holding a trailing CR until the next chunk shows whether an LF + // follows it, so a CRLF split across chunks stays one line ending. + const heldCr = buffer.endsWith("\r"); + buffer = (heldCr ? buffer.slice(0, -1) : buffer).replace(/\r\n?/g, "\n") + (heldCr ? "\r" : ""); + let sep: number; + while ((sep = buffer.indexOf("\n\n")) !== -1) { + const frame = buffer.slice(0, sep); + buffer = buffer.slice(sep + 2); + inspectFrame(frame); + } + }; + // The last block can sit in the buffer without its blank line: an upstream that stopped after + // it, or a held trailing CR. Count it before deciding how the turn ended (the Responses relay + // flushes the same candidate). Returns true when this flush found the terminal in a block the + // client has not seen a blank line after, so the caller restores one. + const flushTail = (): boolean => { + const terminalBefore = terminalSeen; + const tail = (buffer + decoder.decode()).replace(/\r\n?/g, "\n"); + buffer = ""; + if (tail) inspectFrame(tail); + return terminalSeen && !terminalBefore && !tail.endsWith("\n\n"); + }; + const reader = upstream.getReader(); + let settled = false; + let bodyBytes = 0; + let tapController: ReadableStreamDefaultController | undefined; + + const recordUsage = () => { + logCtx.usage = anthropicUsageToOcx(Object.keys(usageAcc).length > 0 ? usageAcc : undefined); + }; + const closeWithErrorFrame = (errType: string, message: string) => { + const payload = JSON.stringify({ type: "error", error: { type: errType, message } }); + try { + // Leading blank line terminates any partial SSE block so the frame parses cleanly + // (relaySseWithFailedTail policy, Anthropic wire shape). + tapController?.enqueue(encoder.encode(`\n\nevent: error\ndata: ${payload}\n\n`)); + tapController?.close(); + } catch { /* client already torn down */ } + }; + const failBody = (closeReason: "body_stall" | "body_overflow", errType: string, message: string) => { + if (settled) return; + settled = true; + idle.cancel(); + detachAbort(); + const terminalInTail = flushTail(); + recordUsage(); + if (terminalSeen) { + // The turn already ended (message_stop or an upstream error event): an upstream that then + // idles or keeps sending did not cut it short. Same rule as the read-error branch, + // including the restored blank line for a terminal found only in the tail. + finalize(200, { closeReason: "terminal" }); + try { + if (terminalInTail) tapController?.enqueue(encoder.encode("\n\n")); + tapController?.close(); + } catch { /* client already torn down */ } + } else { + // A cut-short turn, logged as the Responses relay logs a stall-timeout incomplete + // (httpStatusForRequestLogTerminal: only a max_output_tokens incomplete is a 200). A 200 + // row with no terminalStatus also lost its failure diagnostics in usage.jsonl. + logCtx.upstreamError = message.slice(0, 500); + finalize(502, { terminalStatus: "incomplete", closeReason }); + closeWithErrorFrame(errType, message); + } + reader.cancel(new DOMException(message, closeReason === "body_stall" ? "TimeoutError" : "QuotaExceededError")).catch(() => {}); + }; + const idle = idleDeadline(guard?.stallMs ?? 0, () => { + failBody( + "body_stall", + "timeout_error", + `anthropic passthrough body stalled: no upstream bytes for ${Math.round((guard?.stallMs ?? 0) / 1000)}s`, + ); + }); + // Deterministic client-cancel classification: Bun may surface a client abort as a + // reader.read() rejection OR a resolved done (src/lib/abort.ts cancelBodyOnAbort + // rationale), so the listener performs first-wins settlement itself instead of + // relying on which shape the read takes. + const onClientAbort = () => { + if (settled) return; + settled = true; + idle.cancel(); + detachAbort(); + finalize(499, { closeReason: "client_cancel" }); + try { tapController?.close(); } catch { /* downstream already torn down */ } + reader.cancel(guard?.reqSignal?.reason).catch(() => {}); + }; + const detachAbort = (() => { + const signal = guard?.reqSignal; + if (!signal) return () => {}; + if (signal.aborted) { + queueMicrotask(onClientAbort); + return () => {}; + } + signal.addEventListener("abort", onClientAbort, { once: true }); + return () => signal.removeEventListener("abort", onClientAbort); + })(); + + return new ReadableStream({ + start(controller) { + tapController = controller; + }, + async pull(controller) { + if (settled) return; + try { + idle.reset(); + const { done, value } = await reader.read(); + idle.pause(); + if (settled) return; // stall/overflow/abort won the race while we awaited + if (done) { + settled = true; + idle.cancel(); + detachAbort(); + recordUsage(); + finalize(200, { closeReason: "terminal" }); + controller.close(); + return; + } + if (value.byteLength > 0) { + bodyBytes += value.byteLength; + if (guard && guard.maxBytes > 0 && bodyBytes > guard.maxBytes) { + failBody( + "body_overflow", + "api_error", + `anthropic passthrough body exceeded ${guard.maxBytes} bytes`, + ); + return; + } + } + inspect(value); + controller.enqueue(value); + } catch (err) { + if (settled) return; + // Bun can settle a fetch body read before it dispatches the abort listeners + // (consumeForInspection in relay.ts): read the signal itself before calling this + // rejection an upstream failure. + if (guard?.reqSignal?.aborted) { + onClientAbort(); + return; + } + settled = true; + idle.cancel(); + detachAbort(); + const terminalInTail = flushTail(); + recordUsage(); + if (isTranslatorBudgetExceededError(err)) { + // A local cap, not an upstream failure: the non-streaming native Messages fold + // maps this error to a 413 itself, so it still errors the stream. + finalize(200, { closeReason: "terminal" }); + try { controller.error(err); } catch { /* torn down */ } + return; + } + if (terminalSeen) { + // Only the transport trailer was lost; the client already has the turn's terminal. + // The Responses relay likewise reports a read error only without a seen terminal. + finalize(200, { closeReason: "terminal" }); + try { + // An SSE parser drops an event that EOF cuts off before its blank line, so restore + // the delimiter when the terminal was only found in that unterminated tail. + if (terminalInTail) controller.enqueue(encoder.encode("\n\n")); + controller.close(); + } catch { /* torn down */ } + reader.cancel(err).catch(() => {}); + return; + } + // The upstream read failed after the 200 went out (a mid-stream socket reset). Log it + // the way the Responses relay does (onReadError): a truncated body is a failed turn, + // not a completed one. The client gets the same Anthropic error terminal as a stall, + // instead of a connection reset — or, on some Bun releases, a bare EOF that reads as + // a finished message. + const message = redactSecretString(`anthropic passthrough upstream stream failed: ${err instanceof Error ? err.message : String(err)}`); + logCtx.transportPhase = "mid_stream"; + logCtx.terminalSource = "synthetic"; + logCtx.upstreamError = message.slice(0, 500); + if (logCtx.activeAttempt) logCtx.activeAttempt.streamAborted = true; + finalize(502, { terminalStatus: "failed", closeReason: "terminal" }); + closeWithErrorFrame("api_error", message); + reader.cancel(err).catch(() => {}); + } + }, + cancel(reason) { + if (!settled) { + settled = true; + idle.cancel(); + detachAbort(); + finalize(499, { closeReason: "client_cancel" }); + } + reader.cancel(reason).catch(() => {}); + }, + }); +} + +/** + * `tool_use.id` / `tool_result.tool_use_id` must match Anthropic's wire contract + * (`^[a-zA-Z0-9_-]+$`, <=64 chars). Third-party models mint other shapes — Devin's + * swe-2 emits `Bash:0#` — and a session history carrying them 400s the moment it + * is switched to a native Anthropic model ("messages.N.content.M.tool_use.id: String + * should match pattern"). The adapter path normalizes these via + * adapters/tool-call-id.ts (#1780); this passthrough bypasses that adapter, so the same + * allocator runs here. Stateless per request: conforming ids pass through byte-identical + * (prompt-cache keys untouched), rewritten ids keep call/result pairing stable. + * An empty id has no representable wire form, and forwarding `""` is what Anthropic + * rejects (#1767), so the request fails locally with a 400 before any upstream fetch. + */ +export function sanitizePassthroughToolCallIds(messages: unknown[]): void { + const blocks: Rec[] = []; + for (const message of messages) { + if (!isRec(message) || !Array.isArray(message.content)) continue; + for (const block of message.content) if (isRec(block)) blocks.push(block); + } + const fieldOf = (block: Rec): "id" | "tool_use_id" | undefined => { + if (typeof block.type !== "string") return undefined; + if (block.type.endsWith("tool_use")) return "id"; + if (block.type.endsWith("tool_result")) return "tool_use_id"; + return undefined; + }; + const callIds = createToolCallIdAllocator(); + for (const block of blocks) { + const field = fieldOf(block); + if (field && typeof block[field] === "string") callIds.reserve(block[field] as string); + } + for (const block of blocks) { + const field = fieldOf(block); + if (!field || typeof block[field] !== "string") continue; + const wire = callIds.allocate(block[field] as string); + if (wire === undefined) throw new AnthropicRequestError(`${block.type} block has an empty ${field}`); + block[field] = wire; + } +} + +const DEFAULT_BODY_STALL_SEC = 90; +const DEFAULT_BODY_MAX_BYTES = 64 * 1024 * 1024; + +/** + * Normalize the claudeCode body-guard config (devlog 260716_passthrough_followups/010). + * Policy: exactly 0 disables; finite positive values are honored (stall clamped to + * min 1s); negative/non-finite/absent values fall back to the defaults. + */ +export function resolvePassthroughBodyGuard(config: OcxConfig, reqSignal?: AbortSignal): PassthroughBodyGuard { + const rawSec = config.claudeCode?.bodyStallSec; + const stallSec = rawSec === 0 + ? 0 + : typeof rawSec === "number" && Number.isFinite(rawSec) && rawSec > 0 + ? Math.max(1, rawSec) + : DEFAULT_BODY_STALL_SEC; + const rawBytes = config.claudeCode?.bodyMaxBytes; + const maxBytes = rawBytes === 0 + ? 0 + : typeof rawBytes === "number" && Number.isFinite(rawBytes) && rawBytes > 0 + ? Math.floor(rawBytes) + : DEFAULT_BODY_MAX_BYTES; + return { stallMs: stallSec * 1000, maxBytes, ...(reqSignal ? { reqSignal } : {}) }; +} + +/** Per-attachment token estimate for a base64 payload: real image dimensions when the + * header is sniffable (Anthropic prices images at ~pixels/750), else decoded bytes/512, + * min 256 — the same shape as the Kiro usage estimator (estimateKiroImageTokens). */ +function estimateBase64AttachmentTokens(data: string): number { + const dims = sniffImageDimensions(data); + if (dims) return Math.max(256, Math.ceil((dims.width * dims.height) / 750)); + const unpadded = data.endsWith("==") ? data.length - 2 : data.endsWith("=") ? data.length - 1 : data.length; + return Math.max(256, Math.ceil(Math.floor((unpadded * 3) / 4) / 512)); +} + +/** + * Char-based token estimate for an Anthropic-shaped request body. Base64 attachment + * payloads (image/document blocks in message content, including blocks nested in + * tool_result.content) are counted as a bounded per-attachment estimate instead of raw + * characters: one 2MB screenshot is ~2.7M base64 chars, which the plain chars/token + * divide reports as hundreds of thousands of tokens versus a real cost around 1.6k. + * That breaks the >2x drift bound the estimator is held to (devlog 260711_claude_inbound + * 040 §3); a live 260-message turn whose replayed thinking was 78.8% of the body breached + * it at 3.28x, which is why the estimate is projected onto the settled route. Text and url + * sources are left in place and counted as characters, as is + * anything outside protocol content positions (tool_use.input, tool schemas). + * + * `thinking` selects which replayed thinking fields the SETTLED route serializes, so the measure + * describes the prompt this proxy forwards rather than the one the caller typed. Omitted, the + * whole body counts — correct for the Anthropic-native wire, where nothing is projected away. + * See `claude-request-projection.ts` for why a routed wire must project it out. + */ +export function estimateClaudeRequestTokens( + raw: { system?: unknown; messages?: unknown; tools?: unknown }, + modelId: string | undefined, + thinking: ClaudeThinkingProjection = CLAUDE_NATIVE_THINKING, +): number { + let attachmentTokens = 0; + // Blank base64 payloads ONLY in protocol content positions: message content blocks and + // blocks nested in tool_result.content. tool_use.input and tool schemas can legitimately + // contain attachment-shaped JSON, and those bytes ARE serialized into function_call + // arguments / tool definitions for routed providers, so they must keep counting as text. + // system is text-only per the Anthropic protocol (no attachment sources), so it is + // stringified as-is. + const sanitizeBlock = (block: unknown): unknown => { + if (!block || typeof block !== "object") return block; + const b = block as Record; + if (b.type === "image" || b.type === "document") { + const source = b.source as { type?: unknown; data?: unknown } | undefined; + if (source && typeof source === "object" && source.type === "base64" && typeof source.data === "string") { + attachmentTokens += estimateBase64AttachmentTokens(source.data); + return { ...b, source: { ...(source as Record), data: "" } }; + } + return block; + } + if (b.type === "tool_result" && Array.isArray(b.content)) { + return { ...b, content: (b.content as unknown[]).map(sanitizeBlock) }; + } + return block; + }; + const sanitizedMessages = (messages: unknown): unknown => + Array.isArray(messages) + ? messages.map(message => { + if (!message || typeof message !== "object") return message; + const m = message as Record; + return Array.isArray(m.content) ? { ...m, content: (m.content as unknown[]).map(sanitizeBlock) } : message; + }) + : messages; + const parts: string[] = []; + if (raw.system !== undefined) parts.push(typeof raw.system === "string" ? raw.system : JSON.stringify(raw.system)); + if (raw.messages !== undefined) { + const projected = projectClaudeRequest(raw, thinking); + parts.push(JSON.stringify(sanitizedMessages(projected.messages))); + } + if (raw.tools !== undefined) parts.push(JSON.stringify(raw.tools)); + return Math.max(1, estimateTokens(parts.join("\n"), modelId) + attachmentTokens); +} diff --git a/src/service.ts b/src/service.ts index 923903e2788..ceb10e010de 100644 --- a/src/service.ts +++ b/src/service.ts @@ -13,8 +13,10 @@ export { acquireOwnershipMutationLease, withOwnershipMutationLease } from "./ser export type { ManagingCliRole, ManagingCliObservation, RegisteredManagingCliInvocation, ServiceTakeoverCompatibilityInput, ServiceTakeoverCompatibility } from "./service/ownership-compatibility"; export { registeredManagingCliInvocation, assessServiceTakeoverCompatibility, sameServiceTakeoverCompatibility } from "./service/ownership-compatibility"; export type { ServiceApiTokenOrigin, ProvisionedServiceApiToken } from "./service/guards"; -export { ServiceOwnershipError, isServiceOwnershipError, serviceEnvironmentOwnedHere, assertServiceEnvironmentMatchesInstall, serviceRetryCommand, assertNotAdminToken, assertServiceAuthEnvironment, writeServiceApiTokenFile, assertLiveServiceManagerAllowed } from "./service/guards"; -export { resolveServiceListenPort, installedServiceListenPort, SERVICE_INSTALL_HEALTH_MS, SERVICE_INSTALL_HEALTH_WINDOWS_MS, serviceInstallHealthMs, confirmServiceServing, reportServiceServing, resolvedProxyEnv } from "./service/health"; +export { ServiceOwnershipError, isServiceOwnershipError, serviceEnvironmentOwnedHere, assertServiceEnvironmentMatchesInstall, assertNotAdminToken, writeServiceApiTokenFile, assertLiveServiceManagerAllowed } from "./service/guards"; +export { serviceRetryCommand, assertServiceAuthEnvironment } from "./service/auth-preflight"; +export { resolveServiceListenPort, resolvedProxyEnv } from "./service/definition"; +export { installedServiceListenPort, SERVICE_INSTALL_HEALTH_MS, SERVICE_INSTALL_HEALTH_WINDOWS_MS, serviceInstallHealthMs, confirmServiceServing, reportServiceServing } from "./service/health"; export type { LaunchdLoadState, LaunchdLoadProbe, LaunchdInstallOutcome } from "./service/launchd"; export { buildPlist, reusePreviousPlistPathVariable, expectedLaunchdCommand, launchdListenPort, runLaunchctl, launchctlLoadFailed, launchdJobMatchesPlist, launchdEvictionTargets, probeLaunchdLoadState, installLaunchd, restartLaunchdJob, startLaunchd } from "./service/launchd"; export { systemdListenPort, buildUnit, systemdNeedsDaemonReload, uninstallSystemd, systemdServiceInstallCleanupOps } from "./service/systemd"; diff --git a/src/service/auth-preflight.ts b/src/service/auth-preflight.ts new file mode 100644 index 00000000000..11c06d1ee91 --- /dev/null +++ b/src/service/auth-preflight.ts @@ -0,0 +1,71 @@ +/** + * The install/repair preflight over the data-plane credential, plus the retry command it + * names. Split out of `guards.ts` so the guard module stops importing `diagnostics`. + * + * Direction of the remaining edge is deliberate: this module asks `guards` for + * `assertNotAdminToken` and `diagnostics` for the install verdict, and neither imports it back. + */ +import { loadConfig } from "../config"; +import { isLoopbackHostname } from "../codex/loopback-target"; +import { readServiceApiTokenState, serviceApiTokenFilePath } from "../lib/service-secrets"; +import { diagnoseService } from "./diagnostics"; +import type { ServiceDiagnostic } from "./diagnostics"; +import { assertNotAdminToken } from "./guards"; + +/** + * The `ocx` command a user should rerun for the service state they actually have. + * + * `installed` alone is not enough: `repairService()` refuses a Task-Scheduler-plus-WinSW + * conflict outright, so recommending repair there names a command guaranteed to fail. + * Install IS the valid conflict recovery, because `installWindows` removes the native + * backend first. Exported so the guard tests the real selector rather than a copy of it. + */ +export function serviceRetryCommand( + diag: Pick = diagnoseService(), +): string { + return diag.installed && !diag.conflict ? "ocx service repair" : "ocx service install"; +} + +/** + * Preflight for `service install` / `service repair` on the data-plane credential. + * + * It used to DEMAND `OPENCODEX_API_AUTH_TOKEN` for a non-loopback hostname, and it threw + * even when `~/.opencodex/service-api-token` already held a perfectly good token. That is + * the defect behind the incident this unit exists to close (#4236): an operator exported the + * ADMIN token as OPENCODEX_API_AUTH_TOKEN because `install` asked for a token, the hub then + * crash-looped on `assertNotAdminToken`, and `service repair` asked for the same env var + * again — so the only remembered way to make the command proceed was the thing that broke it. + * + * Nobody should have to export a token by hand to run a hub. {@link writeServiceApiTokenFile} + * provisions one, so the only conditions left that install cannot fix are an admin-token + * collision in the environment and a token file that exists but cannot be used. + */ +export function assertServiceAuthEnvironment(): void { + const config = loadConfig(); + // Both collision checks come BEFORE the loopback short-circuit, because the launch wrapper + // exports the token file unconditionally (`buildServiceShellCommand` cats it whenever it + // exists, whatever the hostname): a management token in either source fences the whole + // management plane closed at boot, even on a loopback install that needs no admission + // secret. Returning early is what let that broken state through. + const present = process.env.OPENCODEX_API_AUTH_TOKEN?.trim(); + if (present) assertNotAdminToken(present); + const state = readServiceApiTokenState(); + // An existing FILE holding the admin token is the incident shape itself, and the first round + // only checked the env var — so install/repair reused it and the hub crash-looped at boot. + // On a machine connected to a hub this same file holds that hub's issued client key, which + // is never a management token, so the check is a no-op there. + if (state.kind === "present") assertNotAdminToken(state.token, process.env, "file"); + if (isLoopbackHostname(config.hostname)) return; + if (present) return; + // Absent is fine — install/repair generates one below. `unsafe` is not: the writer refuses + // to replace a path it cannot vouch for, so say so here, where the operator can still act, + // instead of failing mid-install. Reached from `service repair` as well as `install`, so + // name a command that can actually succeed (see serviceRetryCommand). + if (state.kind !== "unsafe") return; + const diag = diagnoseService(); + throw new Error( + `The data-plane token file cannot be used (${state.reason}): ${serviceApiTokenFilePath()}. ` + + `Move it aside, then rerun \`${serviceRetryCommand(diag)}\`; the service provisions a ` + + "fresh owner-only token and needs nothing from the environment.", + ); +} diff --git a/src/service/cli.ts b/src/service/cli.ts index 011f0246444..9b07622e367 100644 --- a/src/service/cli.ts +++ b/src/service/cli.ts @@ -11,8 +11,10 @@ import { statusWinswRaw, type WinswStatus } from "../lib/winsw"; import { withWindowsServiceMutationLock } from "../lib/windows-service-mutation-lock"; import { maybeShowStarPrompt } from "../cli/star-prompt"; import { serviceStatusReport } from "./diagnostics"; -import { assertServiceEnvironmentMatchesInstall, assertServiceAuthEnvironment } from "./guards"; -import { resolveServiceListenPort, reportServiceServing } from "./health"; +import { assertServiceAuthEnvironment } from "./auth-preflight"; +import { assertServiceEnvironmentMatchesInstall } from "./guards"; +import { resolveServiceListenPort } from "./definition"; +import { reportServiceServing } from "./health"; import { platformOps, proxyStillLiveAfterStop, stopTrackedProxyForServiceCommand, installServiceSafely, installFreshWindowsSchedulerSafely, removeServiceInstallState, isServiceInstalled } from "./orchestration"; import { repairService } from "./repair"; import type { ServiceRepairVerb } from "./repair"; diff --git a/src/service/definition.ts b/src/service/definition.ts new file mode 100644 index 00000000000..a23679a101f --- /dev/null +++ b/src/service/definition.ts @@ -0,0 +1,97 @@ +/** + * The shape of a service definition: the shell command both Unix backends bake into + * their unit/plist, the port they pin into it, and the proxy environment it carries. + * + * Leaf module. It reads config and the token-file path, and asks nothing of the + * backends that consume it — importing it from `launchd.ts`/`systemd.ts` cannot + * route back through them (see `installedServiceListenPort` in `health.ts`, which + * intentionally stays behind because it asks every backend). + */ +import { loadConfig } from "../config"; +import { serviceApiTokenFilePath } from "../lib/service-secrets"; +import { PROXY_ENV_KEYS } from "../lib/proxy-env"; + +export function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + +/** + * Listen port baked into service wrappers / WinSW XML. + * Priority: explicit override → OCX_BAKE_PORT (update restart) → config.port → 10100. + * `config.port === 0` means ephemeral for interactive start; services need a stable pin, + * so treat 0 / invalid like unset (default 10100) instead of baking `--port 0`. + */ +export function resolveServiceListenPort(override?: number): number { + if (typeof override === "number" && Number.isFinite(override) && override > 0 && override <= 65535) { + return Math.trunc(override); + } + const baked = process.env.OCX_BAKE_PORT?.trim(); + if (baked && /^\d+$/.test(baked)) { + const n = Number(baked); + if (n > 0 && n <= 65535) return n; + } + const configured = loadConfig().port; + if (typeof configured === "number" && configured > 0 && configured <= 65535) return configured; + return 10100; +} + +export function buildServiceShellCommand(bun: string, cli: string | null, port = resolveServiceListenPort()): string { + const tokenFile = serviceApiTokenFilePath(); + const args = cli ? `${shellQuote(cli)} start` : "start"; + return `if [ -f ${shellQuote(tokenFile)} ]; then OPENCODEX_API_AUTH_TOKEN="$(cat ${shellQuote(tokenFile)})"; export OPENCODEX_API_AUTH_TOKEN; fi; exec ${shellQuote(bun)} ${args} --port ${port}`; +} + +/** + * The same command shape, launched through a stable `ocx` executable instead of an + * explicit Bun + CLI pair. The token-file preamble is identical and deliberately shared + * in form: the service still reads the token from disk at start and never carries it in + * the unit. + */ +export function buildServiceLauncherShellCommand(launcher: string, port = resolveServiceListenPort()): string { + const tokenFile = serviceApiTokenFilePath(); + return `if [ -f ${shellQuote(tokenFile)} ]; then OPENCODEX_API_AUTH_TOKEN="$(cat ${shellQuote(tokenFile)})"; export OPENCODEX_API_AUTH_TOKEN; fi; exec ${shellQuote(launcher)} start --port ${port}`; +} + +/** + * Shared tail parser for the baked `--port `. + * + * Terminators cover all three artifact shapes: whitespace (batch wrapper, systemd + * unit), `"` (systemd's quoted ExecStart), `<` (WinSW's ``), and `&` (an + * XML-escaped quote). Matched LAST because every artifact carries the Bun and CLI + * paths ahead of the argument, and a path containing the literal must not shadow it. + */ +export function parseBakedListenPort(read: () => string): number | null { + try { + const last = [...read().matchAll(/start --port (\d{1,5})(?:\s|"|&|<|$)/gm)].at(-1); + if (!last) return null; + const n = Number(last[1]); + return n > 0 && n <= 65535 ? n : null; + } catch { + return null; + } +} + +/** + * Outbound proxy settings the installing shell had, resolved for baking into a service + * definition. + * + * A service manager does not inherit the environment of the shell that installed it, and + * `ExecStart=/bin/sh -lc` is dash on Ubuntu/WSL — login dash reads `.profile`, not + * `.bashrc`, which is where proxy exports usually live. So a user who needs a proxy to + * reach the upstream got a service that dialed direct: the socket was reset, the retry + * budget drained, and the request surfaced as `502 Provider unreachable` (#2107). The + * same install driven through `ocx codex-shim` worked, because that path spawns with + * `{ ...process.env }`. + * + * Lower-case variants are honored because curl-style tooling sets them and the runtime's + * own `applyProxyEnv` already treats both cases as equivalent. Only the canonical + * upper-case name is baked, so a definition never carries two spellings of one setting. + */ +export function resolvedProxyEnv(env: NodeJS.ProcessEnv = process.env): { name: string; value: string }[] { + const resolved: { name: string; value: string }[] = []; + for (const key of PROXY_ENV_KEYS) { + const value = env[key]?.trim() || env[key.toLowerCase()]?.trim(); + if (value) resolved.push({ name: key, value }); + } + return resolved; +} diff --git a/src/service/guards.ts b/src/service/guards.ts index 93ad78e174f..f492d2410cc 100644 --- a/src/service/guards.ts +++ b/src/service/guards.ts @@ -2,14 +2,12 @@ import { execSync } from "node:child_process"; import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; import { getConfigDir, loadConfig } from "../config"; import { withConfigMutationLockSync } from "../config/mutation-lock"; -import { hardenReusedServiceApiToken, readServiceApiTokenState, serviceApiTokenFilePath } from "../lib/service-secrets"; +import { hardenReusedServiceApiToken, serviceApiTokenFilePath } from "../lib/service-secrets"; import { tokenCollidesWithAdmin } from "../lib/admin-secrets"; import { randomBytes } from "node:crypto"; import { hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl"; import { recordOwnedConfigPath } from "../lib/config-ownership"; import { isTestHomeGuardArmed } from "../lib/test-home-guard"; -import { diagnoseService } from "./diagnostics"; -import type { ServiceDiagnostic } from "./diagnostics"; import { currentCodexHome, currentOpenCodexHome, resolveServiceState, serviceCodexHomeMatchesInstall, servicePathMatchesInstall } from "./state"; import { resolveCodexSqliteHome } from "../codex/paths"; import type { CodexHomeDeps } from "../codex/home"; @@ -76,20 +74,6 @@ export function assertServiceEnvironmentMatchesInstall(deps: CodexHomeDeps = {}) } } -/** - * The `ocx` command a user should rerun for the service state they actually have. - * - * `installed` alone is not enough: `repairService()` refuses a Task-Scheduler-plus-WinSW - * conflict outright, so recommending repair there names a command guaranteed to fail. - * Install IS the valid conflict recovery, because `installWindows` removes the native - * backend first. Exported so the guard tests the real selector rather than a copy of it. - */ -export function serviceRetryCommand( - diag: Pick = diagnoseService(), -): string { - return diag.installed && !diag.conflict ? "ocx service repair" : "ocx service install"; -} - /** * Refuse a management (admin) token as the data-plane secret. * @@ -139,50 +123,6 @@ export function assertNotAdminToken( ); } -/** - * Preflight for `service install` / `service repair` on the data-plane credential. - * - * It used to DEMAND `OPENCODEX_API_AUTH_TOKEN` for a non-loopback hostname, and it threw - * even when `~/.opencodex/service-api-token` already held a perfectly good token. That is - * the defect behind the incident this unit exists to close (#4236): an operator exported the - * ADMIN token as OPENCODEX_API_AUTH_TOKEN because `install` asked for a token, the hub then - * crash-looped on `assertNotAdminToken`, and `service repair` asked for the same env var - * again — so the only remembered way to make the command proceed was the thing that broke it. - * - * Nobody should have to export a token by hand to run a hub. {@link writeServiceApiTokenFile} - * provisions one, so the only conditions left that install cannot fix are an admin-token - * collision in the environment and a token file that exists but cannot be used. - */ -export function assertServiceAuthEnvironment(): void { - const config = loadConfig(); - // Both collision checks come BEFORE the loopback short-circuit, because the launch wrapper - // exports the token file unconditionally (`buildServiceShellCommand` cats it whenever it - // exists, whatever the hostname): a management token in either source fences the whole - // management plane closed at boot, even on a loopback install that needs no admission - // secret. Returning early is what let that broken state through. - const present = process.env.OPENCODEX_API_AUTH_TOKEN?.trim(); - if (present) assertNotAdminToken(present); - const state = readServiceApiTokenState(); - // An existing FILE holding the admin token is the incident shape itself, and the first round - // only checked the env var — so install/repair reused it and the hub crash-looped at boot. - // On a machine connected to a hub this same file holds that hub's issued client key, which - // is never a management token, so the check is a no-op there. - if (state.kind === "present") assertNotAdminToken(state.token, process.env, "file"); - if (isLoopbackHostname(config.hostname)) return; - if (present) return; - // Absent is fine — install/repair generates one below. `unsafe` is not: the writer refuses - // to replace a path it cannot vouch for, so say so here, where the operator can still act, - // instead of failing mid-install. Reached from `service repair` as well as `install`, so - // name a command that can actually succeed (see serviceRetryCommand). - if (state.kind !== "unsafe") return; - const diag = diagnoseService(); - throw new Error( - `The data-plane token file cannot be used (${state.reason}): ${serviceApiTokenFilePath()}. ` - + `Move it aside, then rerun \`${serviceRetryCommand(diag)}\`; the service provisions a ` - + "fresh owner-only token and needs nothing from the environment.", - ); -} - /** How the data-plane token the service will export was obtained. */ export type ServiceApiTokenOrigin = "env" | "file" | "generated"; diff --git a/src/service/health.ts b/src/service/health.ts index d04c83ef3ff..bcdaf98414c 100644 --- a/src/service/health.ts +++ b/src/service/health.ts @@ -1,77 +1,18 @@ -import { win32 } from "node:path"; import { loadConfig } from "../config"; -import { serviceApiTokenFilePath } from "../lib/service-secrets"; -import { PROXY_ENV_KEYS } from "../lib/proxy-env"; +import { resolveServiceListenPort } from "./definition"; import { proxyIdentityAt } from "../server/proxy-liveness"; import { launchdListenPort } from "./launchd"; import { serviceLogPath } from "./state"; import { systemdListenPort } from "./systemd"; import { windowsListenPort, winswListenPort } from "./windows-ops"; -export function shellQuote(value: string): string { - return `'${value.replace(/'/g, "'\\''")}'`; -} - -/** - * Listen port baked into service wrappers / WinSW XML. - * Priority: explicit override → OCX_BAKE_PORT (update restart) → config.port → 10100. - * `config.port === 0` means ephemeral for interactive start; services need a stable pin, - * so treat 0 / invalid like unset (default 10100) instead of baking `--port 0`. - */ -export function resolveServiceListenPort(override?: number): number { - if (typeof override === "number" && Number.isFinite(override) && override > 0 && override <= 65535) { - return Math.trunc(override); - } - const baked = process.env.OCX_BAKE_PORT?.trim(); - if (baked && /^\d+$/.test(baked)) { - const n = Number(baked); - if (n > 0 && n <= 65535) return n; - } - const configured = loadConfig().port; - if (typeof configured === "number" && configured > 0 && configured <= 65535) return configured; - return 10100; -} - -export function buildServiceShellCommand(bun: string, cli: string | null, port = resolveServiceListenPort()): string { - const tokenFile = serviceApiTokenFilePath(); - const args = cli ? `${shellQuote(cli)} start` : "start"; - return `if [ -f ${shellQuote(tokenFile)} ]; then OPENCODEX_API_AUTH_TOKEN="$(cat ${shellQuote(tokenFile)})"; export OPENCODEX_API_AUTH_TOKEN; fi; exec ${shellQuote(bun)} ${args} --port ${port}`; -} - -/** - * The same command shape, launched through a stable `ocx` executable instead of an - * explicit Bun + CLI pair. The token-file preamble is identical and deliberately shared - * in form: the service still reads the token from disk at start and never carries it in - * the unit. - */ -export function buildServiceLauncherShellCommand(launcher: string, port = resolveServiceListenPort()): string { - const tokenFile = serviceApiTokenFilePath(); - return `if [ -f ${shellQuote(tokenFile)} ]; then OPENCODEX_API_AUTH_TOKEN="$(cat ${shellQuote(tokenFile)})"; export OPENCODEX_API_AUTH_TOKEN; fi; exec ${shellQuote(launcher)} start --port ${port}`; -} - -/** - * Shared tail parser for the baked `--port `. - * - * Terminators cover all three artifact shapes: whitespace (batch wrapper, systemd - * unit), `"` (systemd's quoted ExecStart), `<` (WinSW's ``), and `&` (an - * XML-escaped quote). Matched LAST because every artifact carries the Bun and CLI - * paths ahead of the argument, and a path containing the literal must not shadow it. - */ -export function parseBakedListenPort(read: () => string): number | null { - try { - const last = [...read().matchAll(/start --port (\d{1,5})(?:\s|"|&|<|$)/gm)].at(-1); - if (!last) return null; - const n = Number(last[1]); - return n > 0 && n <= 65535 ? n : null; - } catch { - return null; - } -} +export { resolveServiceListenPort }; +export { shellQuote, buildServiceShellCommand, buildServiceLauncherShellCommand, parseBakedListenPort, resolvedProxyEnv } from "./definition"; /** * The listen port of the INSTALLED service artifact, falling back to the configured - * one. Each reader returns null off its own platform, so the chain needs no platform - * branch — and on Windows both return null, preserving today's behavior. + * one. This historical artifact chain can read retained files from another platform; + * platform-specific launchd command checks instead use only the plist and configured port. */ export function installedServiceListenPort(): number { return launchdListenPort() @@ -239,28 +180,3 @@ export async function reportServiceServing( export function serviceRepairCommand(): string { return "ocx service repair"; } - -/** - * Outbound proxy settings the installing shell had, resolved for baking into a service - * definition. - * - * A service manager does not inherit the environment of the shell that installed it, and - * `ExecStart=/bin/sh -lc` is dash on Ubuntu/WSL — login dash reads `.profile`, not - * `.bashrc`, which is where proxy exports usually live. So a user who needs a proxy to - * reach the upstream got a service that dialed direct: the socket was reset, the retry - * budget drained, and the request surfaced as `502 Provider unreachable` (#2107). The - * same install driven through `ocx codex-shim` worked, because that path spawns with - * `{ ...process.env }`. - * - * Lower-case variants are honored because curl-style tooling sets them and the runtime's - * own `applyProxyEnv` already treats both cases as equivalent. Only the canonical - * upper-case name is baked, so a definition never carries two spellings of one setting. - */ -export function resolvedProxyEnv(env: NodeJS.ProcessEnv = process.env): { name: string; value: string }[] { - const resolved: { name: string; value: string }[] = []; - for (const key of PROXY_ENV_KEYS) { - const value = env[key]?.trim() || env[key.toLowerCase()]?.trim(); - if (value) resolved.push({ name: key, value }); - } - return resolved; -} diff --git a/src/service/launchd.ts b/src/service/launchd.ts index 4014a86d57b..27abda1a402 100644 --- a/src/service/launchd.ts +++ b/src/service/launchd.ts @@ -11,7 +11,7 @@ import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, durableBunRuntime, type D import { serviceApiTokenFilePath } from "../lib/service-secrets"; import { recordOwnedConfigPath } from "../lib/config-ownership"; import { writeServiceApiTokenFile, assertLiveServiceManagerAllowed } from "./guards"; -import { resolveServiceListenPort, buildServiceShellCommand, buildServiceLauncherShellCommand, installedServiceListenPort, resolvedProxyEnv } from "./health"; +import { resolveServiceListenPort, buildServiceShellCommand, buildServiceLauncherShellCommand, resolvedProxyEnv } from "./definition"; import { SERVICE_MANAGED_ENV, LABEL, cliEntry, filterTransientServicePath, logPath, serviceStatePath, currentCodexSqliteHomeAbsolute, type ServiceInstallState, writeServiceInstallState, readServiceInstallState } from "./state"; import { writeServiceDefinitionFile } from "./windows-ops"; import { readTextOrNull } from "./windows-taskxml"; @@ -359,8 +359,11 @@ export function probeLaunchdLoadState(deps: { for (const domain of [`gui/${uid}`, `user/${uid}`]) { const printed = run(["print", `${domain}/${LABEL}`]); if (printed.status === 0) { + // Use this backend's plist, then the configured service port. Foreign artifacts + // can exist on macOS: systemdListenPort has no platform gate, so the general + // installed-artifact chain could incorrectly select a copied Linux unit's port. const expected = (deps.expectedCommand - ?? (() => expectedLaunchdCommand(installedServiceListenPort())))(); + ?? (() => expectedLaunchdCommand(launchdListenPort() ?? resolveServiceListenPort())))(); const printedText = `${printed.stdout}\n${printed.stderr}`; return { state: printedText.includes(expected) ? "loaded-current" : "loaded-stale", @@ -777,6 +780,7 @@ export function restartLaunchdJob(deps: { export function startLaunchd(deps: { launchctl?: typeof runLaunchctl; matches?: typeof launchdJobMatchesPlist; + readPlist?: () => string; } = {}): void { const run = deps.launchctl ?? runLaunchctl; const p = plistPath(); @@ -786,16 +790,24 @@ export function startLaunchd(deps: { // already be bootstrapped from THIS plist, which is a no-op rather than an error. // `install` can assume a stale job (it just rewrote the plist); `start` cannot, and // throwing here would break `ocx service start` on every healthy service. + // A live job can outlast its deleted or unreadable plist. Only a readable + // definition can justify the no-op; use the same snapshot for port parsing. + let plist: string | null; + try { plist = (deps.readPlist ?? (() => readFileSync(p, "utf8")))(); } + catch { plist = null; } + // Match the same launchd-only port choice as probeLaunchdLoadState above. const live = (deps.matches ?? launchdJobMatchesPlist)( - expectedLaunchdCommand(installedServiceListenPort()), + expectedLaunchdCommand(launchdListenPort({ readPlist: () => plist ?? "" }) ?? resolveServiceListenPort()), ); - if (live.loaded && live.matchesPlist) { + if (plist !== null && live.loaded && live.matchesPlist) { console.log("ℹ️ service was already loaded from the current plist; nothing to do."); return; } throw new Error( `launchctl could not load ${p}: ${loaded.stderr || "load reported failure"}\n` - + (live.loaded + + (plist === null + ? "The service plist is missing or unreadable. Run 'ocx service repair' to restore it." + : live.loaded ? `launchd is running an OLDER plist. Fix:\n launchctl bootout ${launchdGuiDomain()}/${LABEL}\n ocx service repair` : "The job is not loaded. Run 'ocx service repair' to reload it."), ); diff --git a/src/service/repair.ts b/src/service/repair.ts index 5b8e4fe544d..615d1bfc890 100644 --- a/src/service/repair.ts +++ b/src/service/repair.ts @@ -6,7 +6,8 @@ import { randomUUID } from "node:crypto"; import { defaultWinswEntry, installWinswService } from "../lib/winsw"; import { resolveWindowsTaskDiagnosticUserId, diagnoseService } from "./diagnostics"; import type { ServiceDiagnostic } from "./diagnostics"; -import { assertServiceEnvironmentMatchesInstall, assertServiceAuthEnvironment } from "./guards"; +import { assertServiceAuthEnvironment } from "./auth-preflight"; +import { assertServiceEnvironmentMatchesInstall } from "./guards"; import { installLaunchd, restartLaunchdJob } from "./launchd"; import type { LaunchdInstallOutcome } from "./launchd"; import { TASK, writeServiceInstallState, serviceSourceDir, resolveServiceOwnership, serviceLauncherPathDiagnostic } from "./state"; diff --git a/src/service/systemd.ts b/src/service/systemd.ts index b73001b6ef4..2604d3188f0 100644 --- a/src/service/systemd.ts +++ b/src/service/systemd.ts @@ -8,7 +8,7 @@ import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, durableBunRuntime, type D import { recordOwnedConfigPath } from "../lib/config-ownership"; import { systemdProperty } from "../service-manager-probe"; import { writeServiceApiTokenFile, sh } from "./guards"; -import { shellQuote, buildServiceShellCommand, buildServiceLauncherShellCommand, resolvedProxyEnv } from "./health"; +import { shellQuote, buildServiceShellCommand, buildServiceLauncherShellCommand, resolvedProxyEnv } from "./definition"; import type { ServiceInstallCleanupOps } from "./orchestration"; import { SERVICE_MANAGED_ENV, TASK, cliEntry, filterTransientServicePath, stableLauncherEntry, logPath, serviceStatePath, currentCodexSqliteHomeAbsolute, writeServiceInstallState } from "./state"; import { writeServiceDefinitionFile } from "./windows-ops"; diff --git a/src/service/windows-guarded-manager.ts b/src/service/windows-guarded-manager.ts index 2425df63e3c..f74c056e161 100644 --- a/src/service/windows-guarded-manager.ts +++ b/src/service/windows-guarded-manager.ts @@ -29,7 +29,8 @@ import { cachedCurrentWindowsIdentity, resolveCurrentWindowsPrincipal, WINDOWS_P import { statusWinswRaw, winswExePath, WINSW_SERVICE_ID, type WinswStatus } from "../lib/winsw"; import type { GuardedManagerStopped, GuardedManagerTarget } from "./guarded-manager-target"; import { TASK, windowsLauncherVbsPath, windowsServiceScriptPath } from "./state"; -import { probeWindowsSchedulerTask, querySchtasks, windowsWscript, type WindowsSchedulerTaskProbe } from "./windows-scheduler"; +import { probeWindowsSchedulerTask, querySchtasks, type WindowsSchedulerTaskProbe } from "./windows-scheduler"; +import { windowsWscript } from "./windows-wscript"; import { windowsTaskRegistrationHealthy, windowsTaskRegistrationRefreshableLegacy } from "./windows-taskxml"; /** Runtime table of one Win32 process; a null parent means the chain is unreadable. */ diff --git a/src/service/windows-ops.ts b/src/service/windows-ops.ts index f75674884d1..772a34ec713 100644 --- a/src/service/windows-ops.ts +++ b/src/service/windows-ops.ts @@ -5,7 +5,7 @@ import { winswXmlPath } from "../lib/winsw"; import { hardenSecretPath } from "../lib/windows-secret-acl"; import { durableBunRuntime, type DurableBunRuntime } from "../lib/bun-runtime"; import { assertSelectedRuntimeWritable, type RuntimePreflightDeps } from "../lib/bun-runtime-preflight"; -import { parseBakedListenPort } from "./health"; +import { parseBakedListenPort } from "./definition"; import { windowsServiceScriptPath } from "./state"; import { execFileSync } from "node:child_process"; import { existsSync, mkdirSync, mkdtempSync, rmdirSync, unlinkSync } from "node:fs"; diff --git a/src/service/windows-scheduler.ts b/src/service/windows-scheduler.ts index 7bc5f2b583c..564fa1f69ef 100644 --- a/src/service/windows-scheduler.ts +++ b/src/service/windows-scheduler.ts @@ -1,7 +1,6 @@ import { execFileSync } from "node:child_process"; import { findLiveProxy } from "../server/proxy-liveness"; import { existsSync, readFileSync } from "node:fs"; -import { join, resolve } from "node:path"; import { randomUUID } from "node:crypto"; import { ELEVATION_REQUEST_TIMEOUT_MS, OCX_ELEVATED_PROTOCOL_FAILED, raceWithTimeout, resolveTrustedWindowsSchtasksExe, startElevatedSchtasksCreateAndRun, runWindowsElevated, toWindowsSchtasksError, WindowsElevationError, type ElevatedSchedulerOutcome, type ElevatedSchtasksCreateAndRunExecution, type ElevatedSchtasksCreateAndRunResult } from "../lib/windows-elevation"; import { statusWinswRaw } from "../lib/winsw"; @@ -10,9 +9,10 @@ import { isTestHomeGuardArmed } from "../lib/test-home-guard"; import { TASK, windowsServiceScriptPath, windowsLauncherVbsPath, windowsTaskXmlPath, writeServiceInstallState } from "./state"; import { buildWindowsSchtasksCreateArgs, windowsTaskRegistrationOwnedByAttempt, windowsTaskRegistrationHealthy } from "./windows-taskxml"; import type { ExpectedWindowsTaskUserId } from "./windows-taskxml"; -import { win32 } from "node:path"; import { WINSW_SERVICE_ID } from "../lib/winsw"; +export { windowsWscript } from "./windows-wscript"; + /** * Decode schtasks stdout. `/query /xml` emits UTF-16LE (often with BOM) because the * registered task document is UTF-16; reading that as UTF-8 makes every health check @@ -69,11 +69,6 @@ function windowsSchtasks(): string { return resolveTrustedWindowsSchtasksExe(); } -export function windowsWscript(): string { - const candidate = join(process.env.SystemRoot ?? "C:\\Windows", "System32", "wscript.exe"); - return existsSync(candidate) ? candidate : "wscript.exe"; -} - let querySchtasksForTests: ((args: string[]) => string) | null = null; export function querySchtasks(args: string[]): string { diff --git a/src/service/windows-taskxml.ts b/src/service/windows-taskxml.ts index cd2578eb8b0..f52f0290bb8 100644 --- a/src/service/windows-taskxml.ts +++ b/src/service/windows-taskxml.ts @@ -1,14 +1,14 @@ import { WINDOWS_WRAPPER_PROTOCOL_ENV, WINDOWS_WRAPPER_STAY_OUT_EXIT_CODE } from "./windows-wrapper-exit"; import { readFileSync } from "node:fs"; import { TASK, windowsServiceScriptPath, windowsLauncherVbsPath, windowsTaskXmlPath } from "./state"; -import { windowsWscript } from "./windows-scheduler"; +import { windowsWscript } from "./windows-wscript"; import { join } from "node:path"; import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV } from "../lib/bun-runtime"; import { REAL_BUN_MIN_BYTES } from "../lib/bun-binary-validator.mjs"; import { serviceApiTokenFilePath } from "../lib/service-secrets"; import { windowsEnvIndirectBatchPathList, windowsEnvIndirectBatchValue } from "../lib/win-paths"; import { cachedCurrentWindowsIdentity, resolveCurrentWindowsPrincipal, WINDOWS_PRINCIPAL_LOOKUP_TIMEOUT_MS } from "../lib/windows-user-principal"; -import { resolveServiceListenPort, resolvedProxyEnv } from "./health"; +import { resolveServiceListenPort, resolvedProxyEnv } from "./definition"; import { cliEntry, filterTransientServicePath, serviceLogPath, currentCodexSqliteHomeAbsolute } from "./state"; function windowsBatchValue(value: string): string { diff --git a/src/service/windows-wscript.ts b/src/service/windows-wscript.ts new file mode 100644 index 00000000000..4ef2449f2e1 --- /dev/null +++ b/src/service/windows-wscript.ts @@ -0,0 +1,14 @@ +/** + * The `wscript.exe` the Task Scheduler XML must name. + * + * Leaf module on purpose: `windows-taskxml.ts` needs it to render the task document, + * and taking it from `windows-scheduler.ts` made the two one cyclic component. + * `windows-scheduler` owns the schtasks process surface, not this one path lookup. + */ +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +export function windowsWscript(): string { + const candidate = join(process.env.SystemRoot ?? "C:\\Windows", "System32", "wscript.exe"); + return existsSync(candidate) ? candidate : "wscript.exe"; +} diff --git a/structure/overview.md b/structure/overview.md index 458ad7483bd..f5cab64283f 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -131,6 +131,15 @@ still cover the rule, which is a judgement only review makes. activation checks are the sanctioned pattern. Gated Lab activation also stays synchronous until `startServer` returns; see [`compatibility-lab.md`](adapters/compatibility-lab.md). Enforced by `tests/lab/core-lab-boundary.test.ts`. +- **INV-LAYER-01** — The `src/` static value-import graph only gets less cyclic. Every module still + on a cycle is listed in `tests/fixtures/import-cycle-baseline.json`; a module outside it joining a + cycle fails, and a listed module that leaves every cycle has to be dropped with + `bun scripts/import-cycles.ts --update`, which never adds one. `src/config.ts` and the core + request-path entrypoints sit on no cycle at all. Type-only imports and dynamic `import()` form no + edge, so deferring an edge behind `import()` is a sanctioned way to break a loop (issue #6386). + Bun parses the declarations and resolves local file and directory imports, preserving compact + syntax, erased type-only specifiers, and the runtime's TypeScript index-file precedence. + Enforced by `tests/ci-workflows/import-cycle-ratchet.test.ts`. - **INV-RESTORE-01** — `ocx restore` restores native Codex from the pristine catalog with retired bare/account-qualified native rows omitted from the output; the original backup stays unchanged. The service-stop and uninstall paths of the same promise are covered diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index c48c79568f3..80adbf549b4 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -580,6 +580,8 @@ Antigravity catalog rows carry the observed complete effort map from `src/provid Known Claude Sonnet and Opus 5.5 base and low/medium/high IDs share a deterministic usage identity in `src/providers/antigravity-models.ts`, independently of live discovery. This accounting map does not migrate saved routing selections; Claude 4.6 and unknown suffix identities remain unchanged. Antigravity model entitlement is per account: one account may list Claude 4.6, another only the Claude 5.5 suffix families, and Gemini 4 Argon only on Ultra/Enterprise. `ANTIGRAVITY_MODELS` is therefore a static superset (Gemini 3.8 Flash, Gemini 4 Argon, Claude Opus 5.5, Claude Sonnet 5.5), and `ANTIGRAVITY_KNOWN_PICKER_MODELS` keeps dropped bases known to routing, discovery collapse and usage identity. The catalog exposes only what the active account's authoritative discovery returns. `src/providers/antigravity-account-roster.ts` records each account's discovered roster (in memory, six-hour freshness, scoped to config home); `src/oauth/generic-account-failover.ts` ranks Antigravity accounts by it the way it ranks Kiro listings: a listing account wins, a known non-listing account is skipped while another remains, and an account without a fresh roster is unknown and stays usable. Gemini 4 Argon requests and Antigravity model discovery send the Antigravity CLI User-Agent; every other model keeps the IDE User-Agent. When the `retrieveUserQuotaSummary` endpoint has no usable result, `src/providers/quota/antigravity.ts` preserves bounded per-model `quotaInfo` measurements from `fetchAvailableModels` alongside the existing first-seen Gem/Cla family summaries. Model windows carry the exact CCA wire ID and distinct labels of at most 128 characters for display and diagnostics; GUI summaries and native compact rows preserve the discriminator, retain details, and exclude model readings from headline and severity selection; account and provider routing ignore these rows and continue to use family summaries, and `src/quota/window-mapping.ts` keeps them out of quota-reset observation so one pool reset notifies once. A missing model row or failed probe is unknown evidence, not a model eligibility verdict. Per-account cache entries are bound to the current credential and project identity. Paused reads retain only current last-good measurements without probing; `src/providers/quota/account-row.ts` preserves private currentness callbacks through account-row projection until management publication. +Account-row projection is loaded with `import()` after each account probe in `src/providers/quota.ts`; its cache dependencies do not create a static return edge into the quota facade. + ## Combo model refusal evidence `src/combos/failover.ts` recognizes a bounded, exact HTTP 400 plan/model refusal, not credential or entitlement evidence. diff --git a/structure/runtime.md b/structure/runtime.md index f0994bbc8c9..fdbbf039411 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -121,12 +121,12 @@ does not perform OAuth, and runtime credential resolution rereads the owned sour | `src/cli/status.ts` / `src/cli/status-probes.ts` | Status snapshot assembly and the shared read-only health/stale-process probes used by status and doctor. Probe evidence keeps recorded-port choice, before/after snapshots and per-call timer cleanup together. | | `src/cli/doctor.ts` | Read-only environment diagnostics. Sections print through `console.log`; each is a `collect*` helper above `runDoctor` so it is testable without the command. Only a `FAIL`-level condition records a doctor failure — a degraded-but-working install must not break a green pipeline. `collectDefaultModelExposure` compares Codex's root `model` pin against the exposed set, which it READS rather than recomputes: the running proxy's `/v1/models` through the byte-capped direct-local transport when one answers, otherwise the on-disk catalog's `visibility: "list"` slugs. It reports exposed, not exposed, or undeterminable, and never the second when it could not read either surface. | | `src/router.ts` | Provider/model selection before adapter dispatch. Policy execution and ordinary management dry-run share effective-provider capability evidence; unresolved, missing, and disabled providers are excluded before scoring. | -| `src/providers/api-key-selection-capture.ts` | Pure request-owned snapshot of the configured key entry, reference, and revision. The router and stateful selection module share this leaf with type-only dependencies; `api-key-selection.ts` retains the compatibility export and owns persisted selection changes and route resolution. | +| `src/providers/api-key-selection-capture.ts` | Pure request-owned snapshot of the configured key entry, reference, and revision. The router and stateful selection module share this leaf with type-only dependencies; `api-key-selection.ts` retains the compatibility export and owns persisted selection changes. `src/providers/routed-config.ts` owns request-time provider/registry resolution and the discarded-base-URL warning memo; key selection imports that leaf instead of the router, while `src/router.ts` retains the resolver and warning-reconciliation compatibility exports. The leaf preserves GCP service-account credential markers verbatim until the Vertex adapter resolves the selected account per request. | | `src/types.ts` | Shared config, parsed request, adapter, and event types. | | `src/reasoning-effort.ts` | Codex reasoning-level definitions (`low`/`medium`/`high`/`xhigh`), per-model effort mapping, and catalog effort sanitization. | | `src/cli/tray-proxy.ts` | Proxy restart coordination, bounded replacement observation and the [guarded CLI update restart contract](ops/service-and-sidecars.md#guarded-cli-update-restart). | | `src/codex/shim.ts` | Codex autostart facade: dispatches Unix private PATH overlays to `src/codex/shim-overlay.ts` and retains Windows in-place script wrapping. `src/codex/shim-migration.ts` owns explicit legacy Unix restoration. Legacy Unix shim migration rejects symlink, foreign-owned, and group/world-writable state files. The writable-mode refusal prints a shell-quoted `chmod 600` hint; it clears only that mode refusal, leaving file-type and ownership checks intact. `tests/codex-integration/codex-shim-migration.test.ts` executes the hint through `/bin/sh` with a space and apostrophe in the state path, then verifies mode `0600` and schema-2 migration. `src/codex/shim-diagnostics.ts` owns diagnosis/status, distinguishes runnable from active overlays and states the unhealthy verdict for a damaged in-place (Windows) wrapper. `src/codex/shim-templates.ts` uses `src/lib/self-launch-argv.ts` to invoke standalone executables with `ensure` directly in Unix, CMD and PowerShell wrappers, without a virtual source entrypoint; management subcommands bypass startup even after value-taking global flags. Discovery resolves fnm’s temporary `fnm_multishells` directory to the durable installation directory through `src/codex/shim-path-resolution.ts`, taking the directory realpath while retaining the command basename; unresolved temporary entries refuse installation. The injected path flavour governs join and separator. The shared PATH scanner follows npm/fnm symlinks and POSIX target execute access, skips non-executable and special-file entries, and reads only the first 16 KiB of regular files through a descriptor with nonblocking POSIX open and a post-open type check. An unreadable executable still shadows later commands. Overlay activation requires the selected executable to identify the recorded private wrapper or the same file through an alias; another marker-bearing shim does not prove activation. Shell functions/aliases and empty PATH components are outside this scanner’s guarantee. `src/cli/codex-shim-readiness.ts` retains connect’s advisory exit policy. `tests/codex-integration/codex-shim-path-readiness.test.ts` covers POSIX shell lookup parity, FIFO replacement, symlinks and bounded reads. Windows retains PATHEXT lookup without POSIX execute-access checks. | -| `src/service.ts` | OS service manager (macOS launchd, Linux systemd, Windows schtasks and native WinSW): always-on proxy with crash restart. The scheduler wrapper logs transactional-backup recovery without interpolating backup names. Facade over the `src/service/` leaves — `src/service/launchd.ts`, `src/service/systemd.ts`, `src/service/windows-ops.ts`, `src/service/windows-scheduler.ts`, `src/service/windows-taskxml.ts`, `src/service/state.ts`, `src/service/guards.ts`, `src/service/health.ts`, `src/service/repair.ts`, `src/service/orchestration.ts`, `src/service/diagnostics.ts`, `src/service/cli.ts`. Service definitions filter shell-local multishell PATH entries, including WinSW XML generated during install and repair, and recorded temporary launchers are replaced during repair; changed launchd definitions are reloaded. Codex-home ownership accepts either the recorded path or the same existing physical directory so path aliases remain compatible across upgrades. Elevated Task Scheduler repair stages bounded payloads; the unelevated launcher pins every namespace ancestor and payload with non-reparse handles that deny write/delete sharing on the payload and delete sharing on each ancestor until UAC processing exits. On Windows, `src/service/windows-process-priority.ts` raises the process that binds the proxy to ABOVE_NORMAL just before `startServer` (best-effort; `OCX_DISABLE_PRIORITY_BOOST=1` opts out), because the NORMAL task priority (`4`) still let a saturated host hold `/healthz` past the 750 ms CLI liveness ceiling; spawned children fall back to NORMAL since Windows does not inherit ABOVE_NORMAL. The Task Scheduler batch wrapper (`src/service/windows-taskxml.ts`) checks bundled Bun against `REAL_BUN_MIN_BYTES` before each launch: npm's placeholder, or a file that vanished before its size was read, is logged and re-checked every five seconds instead of executed, while a Bun path missing at the exist check keeps the backup-restore then exit-3 path ([wrapper contract](ops/docs-and-release.md#windows-service-wrapper-and-incomplete-updates)). | +| `src/service.ts` | OS service manager (macOS launchd, Linux systemd, Windows schtasks and native WinSW): always-on proxy with crash restart. The scheduler wrapper logs transactional-backup recovery without interpolating backup names. Facade over the `src/service/` leaves — `src/service/launchd.ts`, `src/service/systemd.ts`, `src/service/windows-ops.ts`, `src/service/windows-scheduler.ts`, `src/service/windows-taskxml.ts`, `src/service/state.ts`, `src/service/guards.ts`, `src/service/health.ts`, `src/service/repair.ts`, `src/service/orchestration.ts`, `src/service/diagnostics.ts`, `src/service/cli.ts`. Launchd start and load-state checks select the installed plist port, falling back to the configured service port (including `OCX_BAKE_PORT`). A failed-load already-running no-op also requires a readable installed plist; a missing or unreadable plist requires repair even when the live command matches. Copied systemd or Windows artifacts are not launchd port evidence; the generic artifact readers are not all platform-gated. `tests/service/launchd-port-fallback.test.ts` covers missing/unreadable plist refusal, readable malformed-port fallback with a foreign systemd port that differs from config, and installed-plist precedence, using synthetic reads and injected manager calls. Service definitions filter shell-local multishell PATH entries, including WinSW XML generated during install and repair, and recorded temporary launchers are replaced during repair; changed launchd definitions are reloaded. Codex-home ownership accepts either the recorded path or the same existing physical directory so path aliases remain compatible across upgrades. Elevated Task Scheduler repair stages bounded payloads; the unelevated launcher pins every namespace ancestor and payload with non-reparse handles that deny write/delete sharing on the payload and delete sharing on each ancestor until UAC processing exits. On Windows, `src/service/windows-process-priority.ts` raises the process that binds the proxy to ABOVE_NORMAL just before `startServer` (best-effort; `OCX_DISABLE_PRIORITY_BOOST=1` opts out), because the NORMAL task priority (`4`) still let a saturated host hold `/healthz` past the 750 ms CLI liveness ceiling; spawned children fall back to NORMAL since Windows does not inherit ABOVE_NORMAL. The Task Scheduler batch wrapper (`src/service/windows-taskxml.ts`) checks bundled Bun against `REAL_BUN_MIN_BYTES` before each launch: npm's placeholder, or a file that vanished before its size was read, is logged and re-checked every five seconds instead of executed, while a Bun path missing at the exist check keeps the backup-restore then exit-3 path ([wrapper contract](ops/docs-and-release.md#windows-service-wrapper-and-incomplete-updates)). | `src/cli/provider.ts` accepts the Google-only `--google-tool-schema-policy` creation flag and rejects an unknown value or non-Google effective adapter before persistence. The persisted field and default diff --git a/tests/ci-workflows/import-cycle-ratchet.test.ts b/tests/ci-workflows/import-cycle-ratchet.test.ts new file mode 100644 index 00000000000..f189708b445 --- /dev/null +++ b/tests/ci-workflows/import-cycle-ratchet.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { + BASELINE_REL, + cyclicComponents, + cyclicModules, + evaluate, + loadBaseline, + scanGraph, + staticImports, + updateBaseline, + type ImportGraph, +} from "../../scripts/import-cycles"; +import { repoRoot } from "../helpers/repo-root"; + +/** + * INV-LAYER-01: the runtime import graph only gets less cyclic (issue #6386). + * + * The baseline in tests/fixtures/import-cycle-baseline.json lists every src/ module that still sits + * on a static value-import cycle. A module outside the list joining a cycle fails here, and so does + * a listed module that has left one: shrinking the list is what stops a later change from quietly + * re-closing the same loop. The core entrypoints below are held to a stricter rule: they sit on no + * cycle at all, so INV-LAB-01's boundary rests on a layered graph instead of on one component that + * reaches everything. + */ +const CORE_OFF_CYCLE = [ + "src/config.ts", + "src/server/lifecycle.ts", + "src/server/responses/core.ts", + "src/server/management-api.ts", +] as const; + +const graphOf = (edges: Record): ImportGraph => new Map(Object.entries(edges)); + +describe("import-cycle ratchet: scanner", () => { + test("parses compact declarations without treating comments or erased imports as edges", () => { + expect(staticImports([ + '#!/usr/bin/env bun', + 'import{ b }from"./b";export{ c }from"./c";', + 'import"./side";export*from"./star";', + 'import/*gap*/{ d }/*gap*/from"./d";', + 'import type{ T }from"./types";export type{ T }from"./types";', + 'import{type U}from"./erased";export{type U}from"./erased";', + '// import{ fake }from"./comment";', + 'const text = `import{ fake }from"./text"`;', + 'const lazy = () => import("./lazy");', + ].join("\n"))).toEqual(["./b", "./c", "./side", "./star", "./d"]); + }); + + test.each(["tsx", "mts"])("directory index.%s imports close cycles", extension => { + const root = mkdtempSync(join(tmpdir(), "ocx-import-index-")); + mkdirSync(join(root, "src", "nested"), { recursive: true }); + writeFileSync(join(root, "src", "a.ts"), 'import { b } from "./nested"; export const a = b;'); + writeFileSync(join(root, "src", "nested", `index.${extension}`), + 'import { a } from "../a"; export const b = a;' + (extension === "tsx" ? " export const view = ;" : "")); + expect(cyclicModules(scanGraph(root))).toEqual(["src/a.ts", `src/nested/index.${extension}`]); + }); + + test("directory precedence agrees with Bun when multiple indexes exist", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-import-precedence-")); + const src = join(root, "src"); + mkdirSync(join(src, "nested"), { recursive: true }); + writeFileSync(join(src, "a.ts"), 'import { b } from "./nested";'); + for (const ext of ["ts", "tsx", "mts"]) writeFileSync(join(src, "nested", `index.${ext}`), "export const b = 1;"); + const resolved = basename(Bun.resolveSync("./nested", src)); + expect(scanGraph(root).get("src/a.ts")).toEqual([`src/nested/${resolved}`]); + }); + + test("only load-time value imports form edges", () => { + const source = [ + 'import { a } from "./a";', + 'import type { B } from "./b";', + 'import "./c";', + 'export { d } from "./d";', + 'export type { E } from "./e";', + 'const lazy = () => import("./f");', + "import {", + " g,", + '} from "./g";', + ].join("\n"); + expect(staticImports(source)).toEqual(["./a", "./c", "./d", "./g"]); + }); + + test("components: a loop and a self-import are cycles, a chain is not", () => { + const graph = graphOf({ + "a.ts": ["b.ts"], + "b.ts": ["c.ts"], + "c.ts": ["a.ts", "d.ts"], + "d.ts": [], + "e.ts": ["e.ts"], + "f.ts": ["d.ts"], + }); + expect(cyclicComponents(graph)).toEqual([["a.ts", "b.ts", "c.ts"], ["e.ts"]]); + expect(cyclicModules(graph)).toEqual(["a.ts", "b.ts", "c.ts", "e.ts"]); + }); + + test("scanGraph resolves relative specifiers and ignores deferred and erased edges", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-import-cycles-")); + mkdirSync(join(root, "src", "nested"), { recursive: true }); + writeFileSync(join(root, "src", "a.ts"), 'import { b } from "./nested";\nexport const a = 1;\n'); + writeFileSync(join(root, "src", "nested", "index.ts"), 'import { a } from "../a";\nexport const b = a;\n'); + writeFileSync(join(root, "src", "lazy.ts"), 'import type { A } from "./typed";\nexport const l = () => import("./a");\n'); + writeFileSync(join(root, "src", "typed.ts"), 'import { l } from "./lazy";\nexport type A = typeof l;\n'); + writeFileSync(join(root, "src", "ambient.d.mts"), 'export const declarationOnly: number;'); + expect(cyclicModules(scanGraph(root))).toEqual(["src/a.ts", "src/nested/index.ts"]); + }); +}); + +describe("import-cycle ratchet: baseline rules", () => { + test("a new cyclic module is reported as added, a fixed one as resolved", () => { + const verdict = evaluate(["src/a.ts", "src/new.ts"], { cyclic: ["src/a.ts", "src/gone.ts"] }); + expect(verdict).toEqual({ added: ["src/new.ts"], resolved: ["src/gone.ts"] }); + }); + + test("updating the baseline only removes members", () => { + expect(updateBaseline(["src/a.ts", "src/new.ts"], { cyclic: ["src/a.ts", "src/gone.ts"] })).toEqual({ + cyclic: ["src/a.ts"], + }); + }); +}); + +describe("import-cycle ratchet: repository", () => { + const root = repoRoot(); + const current = cyclicModules(scanGraph(root)); + const baseline = loadBaseline(root); + + test("the baseline is sorted and unique", () => { + expect(baseline.cyclic).toEqual([...new Set(baseline.cyclic)].sort()); + }); + + test("no module outside " + BASELINE_REL + " sits on an import cycle", () => { + const { added } = evaluate(current, baseline); + if (added.length > 0) { + const components = cyclicComponents(scanGraph(root)).filter((c) => c.some((m) => added.includes(m))); + throw new Error( + "new import cycle(s), one strongly connected component per line; break the loop in source (import the owning module, or defer the edge with import()):\n" + + components.map((c) => " [" + c.join(", ") + "]").join("\n"), + ); + } + }); + + test("modules that left every cycle are dropped from the baseline", () => { + const { resolved } = evaluate(current, baseline); + if (resolved.length > 0) { + throw new Error( + "these modules are no longer on a cycle; run \`bun scripts/import-cycles.ts --update\`:\n " + resolved.join("\n "), + ); + } + }); + + test("the core entrypoints and the config facade sit on no cycle", () => { + expect(CORE_OFF_CYCLE.filter((path) => current.includes(path))).toEqual([]); + }); +}); diff --git a/tests/clients/client-link-runtime.test.ts b/tests/clients/client-link-runtime.test.ts index 805bc62192a..cbe3c033165 100644 --- a/tests/clients/client-link-runtime.test.ts +++ b/tests/clients/client-link-runtime.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { getDefaultConfig, saveConfig } from "../../src/config"; import { serviceApiTokenFingerprint } from "../../src/lib/service-secrets"; -import { bindClientListener, startClientRuntime } from "../../src/client/runtime"; +import { bindClientListener, scheduleStandaloneRecycle, startClientRuntime } from "../../src/client/runtime"; import type { MachineListenerDeps } from "../../src/client/machine-listener"; import type { OcxClientConnectionConfig } from "../../src/types"; import { clientLinkStatePath, writeClientLinkState } from "../../src/client/link-state"; @@ -190,7 +190,7 @@ test("an ended link recycles a connected sibling after listener cleanup", async }, watchdogMs(60_000)); describe("link runtime waits for its configured port like a hard-pinned start", () => { - test("the bind helper forwards link status, cached key, and tunnel gate", async () => { + test("the bind helper forwards the runtime recycle scheduler, link status, cached key, and tunnel gate", async () => { const port = freePort(); const linkStatus = () => ({ kind: "stopped" as const }); const linkKeySource = () => "link-key"; @@ -209,6 +209,7 @@ describe("link runtime waits for its configured port like a hard-pinned start", return (bound = servePlain(listenPort!)); }, }); + expect(received?.machineApi?.scheduleStandaloneRecycle).toBe(scheduleStandaloneRecycle); expect(received?.linkStatus).toBe(linkStatus); expect(received?.linkKeySource).toBe(linkKeySource); expect(received?.linkTunnel).toBe(linkTunnel); diff --git a/tests/clients/client-machine-listener.test.ts b/tests/clients/client-machine-listener.test.ts index 02d3a83affe..2b4ab69e3d5 100644 --- a/tests/clients/client-machine-listener.test.ts +++ b/tests/clients/client-machine-listener.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import type { Server } from "bun"; import { startMachineListener } from "../../src/client/machine-listener"; +import { handleMachineApi } from "../../src/client/machine-api"; import { serveGuiFile } from "../../src/server/gui-static"; import type { OcxClientConnectionConfig, OcxConfig } from "../../src/types"; import { RemoteWorkspaceSessionService } from "../../src/remote-control/workspace-sessions"; @@ -267,6 +268,30 @@ describe("client machine listener", () => { expect(recycled).toBe(false); }); + test("machine api disconnect recycles through the injected schedule and skips it on failure", async () => { + const recycled: string[] = []; + const state = connection(); + const url = new URL("/api/machine/disconnect", "https://client.example.test"); + const request = (): Request => new Request(url, { method: "POST", body: JSON.stringify({}) }); + const deps = { + sync: async () => ({ catalogWritten: false, cacheSynced: true, injected: true, stale: false }), + disconnect: async () => ({ restored: true, tokenRemoved: true, catalogRemoved: true, apiKeyId: state.apiKeyId }), + scheduleStandaloneRecycle: (tokenFingerprint: string) => { recycled.push(tokenFingerprint); }, + }; + + const okResponse = await handleMachineApi(request(), url, state, deps); + expect(okResponse?.status).toBe(202); + expect(recycled).toEqual([state.tokenFingerprint]); + + recycled.length = 0; + const failedResponse = await handleMachineApi(request(), url, state, { + ...deps, + disconnect: async () => { throw new Error("disconnect failed"); }, + }); + expect(failedResponse?.status).toBe(409); + expect(recycled).toEqual([]); + }); + test("refuses startup without matching durable connected state", () => { expect(() => startMachineListener(0, { managementAuthState: authState() })).toThrow(/requires connected client state/); }); diff --git a/tests/codex-integration/codex-account-delete-atomicity.test.ts b/tests/codex-integration/codex-account-delete-atomicity.test.ts index dea566c182a..b1df61a9400 100644 --- a/tests/codex-integration/codex-account-delete-atomicity.test.ts +++ b/tests/codex-integration/codex-account-delete-atomicity.test.ts @@ -31,6 +31,8 @@ import { import { getConfigPath, loadConfig, saveConfig } from "../../src/config"; import * as configModule from "../../src/config"; import { setCodexAccountAutoSwitchThresholdOverride } from "../../src/codex/account-auto-switch"; +import { clearAllManualPreferences, manualPreferenceBlocks, resetCodexRoutingForManualSelection } from "../../src/codex/routing/active-account"; +import { POOL_KEY_CODEX } from "../../src/oauth/pool-kernel"; import { prepareConfigObjectChildDeletionRebase } from "../../src/config/rebase-provenance"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -414,4 +416,17 @@ describe("Codex account delete persistence ordering", () => { expect(getCodexAccountCredential(ACCOUNT_ID)).toBeNull(); expect(loadConfig().codexAccountNamespaces).toEqual({ stable: ACCOUNT_ID }); }); + + test("deleting the account drops its manual routing preference", () => { + const config = seededConfig(); + resetCodexRoutingForManualSelection(ACCOUNT_ID); + expect(manualPreferenceBlocks(POOL_KEY_CODEX, "other-account")).toBe(true); + try { + expect(deleteCodexAccount(config, ACCOUNT_ID)).toBe(true); + // Checked before the cleanup below, which would clear the preference on its own. + expect(manualPreferenceBlocks(POOL_KEY_CODEX, "other-account")).toBe(false); + } finally { + clearAllManualPreferences(); + } + }); }); diff --git a/tests/codex-integration/codex-account-threshold-api.test.ts b/tests/codex-integration/codex-account-threshold-api.test.ts index 06a4ff714bf..4157797fec1 100644 --- a/tests/codex-integration/codex-account-threshold-api.test.ts +++ b/tests/codex-integration/codex-account-threshold-api.test.ts @@ -15,7 +15,7 @@ import { } from "../../src/server/lifecycle"; import { fallbackCodexAccountLogLabel } from "../../src/codex/account-label"; import { - handleCodexAuthAPI, updateAccountQuota, getAccountQuota, + updateAccountQuota, getAccountQuota, checkAccountIdCollision, getMainChatgptAccountId, markAccountNeedsReauth, isAccountNeedsReauth, clearAccountNeedsReauth, clearAccountQuota, clearMainAccountInfoCache, maskEmail, fetchMainAccountInfo, fetchMainAccountInfoSnapshot, @@ -24,6 +24,7 @@ import { listCodexAuthAccounts, setAccountQuotaFromParsed, } from "../../src/codex/auth-api"; +import { handleCodexAuthAPI } from "../../src/codex/auth-api/routes"; import { getCodexAccountCredential, listCodexAccountIds, diff --git a/tests/codex-integration/codex-account-threshold-auth.test.ts b/tests/codex-integration/codex-account-threshold-auth.test.ts index 362c72c51d9..3bb009f73a7 100644 --- a/tests/codex-integration/codex-account-threshold-auth.test.ts +++ b/tests/codex-integration/codex-account-threshold-auth.test.ts @@ -54,11 +54,11 @@ import { import { clearAccountNeedsReauth, clearAccountQuota, - handleCodexAuthAPI, isAccountNeedsReauth, markAccountNeedsReauth, setAccountQuotaFromParsed, } from "../../src/codex/auth-api"; +import { handleCodexAuthAPI } from "../../src/codex/auth-api/routes"; import { __resetGuardianState, guardianSweep } from "../../src/oauth/token-guardian"; import { CODEX_THREAD_AFFINITY_IDLE_TTL_MS, diff --git a/tests/codex-integration/codex-account-threshold-routing.test.ts b/tests/codex-integration/codex-account-threshold-routing.test.ts index 8d70782c212..38db7641202 100644 --- a/tests/codex-integration/codex-account-threshold-routing.test.ts +++ b/tests/codex-integration/codex-account-threshold-routing.test.ts @@ -41,12 +41,12 @@ import { clearAccountNeedsReauth, clearAccountQuota, getAccountQuota, - handleCodexAuthAPI, isAccountNeedsReauth, parseUsageQuota, setAccountQuotaFromParsed, updateAccountQuota, } from "../../src/codex/auth-api"; +import { handleCodexAuthAPI } from "../../src/codex/auth-api/routes"; import { CODEX_UNKNOWN_USAGE_SCORE, isCodexQuotaExhausted } from "../../src/codex/quota"; import { setCodexAccountPriority } from "../../src/codex/account-priority"; import { MAIN_CODEX_ACCOUNT_ID } from "../../src/codex/main-account"; diff --git a/tests/codex-integration/codex-auth-api.test.ts b/tests/codex-integration/codex-auth-api.test.ts index 19014a9e5dd..4ed7e9fbf0f 100644 --- a/tests/codex-integration/codex-auth-api.test.ts +++ b/tests/codex-integration/codex-auth-api.test.ts @@ -16,7 +16,7 @@ import { } from "../../src/server/lifecycle"; import { fallbackCodexAccountLogLabel } from "../../src/codex/account-label"; import { - handleCodexAuthAPI, updateAccountQuota, getAccountQuota, + updateAccountQuota, getAccountQuota, checkAccountIdCollision, getMainChatgptAccountId, markAccountNeedsReauth, isAccountNeedsReauth, clearAccountNeedsReauth, clearAccountQuota, clearMainAccountInfoCache, maskEmail, fetchMainAccountInfo, fetchMainAccountInfoSnapshot, @@ -25,6 +25,7 @@ import { listCodexAuthAccounts, setAccountQuotaFromParsed, } from "../../src/codex/auth-api"; +import { handleCodexAuthAPI } from "../../src/codex/auth-api/routes"; import { getCodexAccountCredential, listCodexAccountIds, diff --git a/tests/codex-integration/codex-auth-context.test.ts b/tests/codex-integration/codex-auth-context.test.ts index 77c9be839bd..9f1244eb43e 100644 --- a/tests/codex-integration/codex-auth-context.test.ts +++ b/tests/codex-integration/codex-auth-context.test.ts @@ -53,11 +53,11 @@ import { import { clearAccountNeedsReauth, clearAccountQuota, - handleCodexAuthAPI, isAccountNeedsReauth, markAccountNeedsReauth, setAccountQuotaFromParsed, } from "../../src/codex/auth-api"; +import { handleCodexAuthAPI } from "../../src/codex/auth-api/routes"; import { __resetGuardianState, guardianSweep } from "../../src/oauth/token-guardian"; import { CODEX_THREAD_AFFINITY_IDLE_TTL_MS, diff --git a/tests/codex-integration/codex-credits-after-limit.test.ts b/tests/codex-integration/codex-credits-after-limit.test.ts index e0991410464..2fbb876cc6e 100644 --- a/tests/codex-integration/codex-credits-after-limit.test.ts +++ b/tests/codex-integration/codex-credits-after-limit.test.ts @@ -20,7 +20,8 @@ import { import { setAccountQuotaFromParsed } from "../../src/codex/quota"; import { clearPoolRotationState } from "../../src/codex/pool-rotation"; import { saveCodexAccountCredential } from "../../src/codex/account-store"; -import { clearAccountNeedsReauth, clearAccountQuota, handleCodexAuthAPI, updateAccountQuota } from "../../src/codex/auth-api"; +import { clearAccountNeedsReauth, clearAccountQuota, updateAccountQuota } from "../../src/codex/auth-api"; +import { handleCodexAuthAPI } from "../../src/codex/auth-api/routes"; import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; import { flushConfigDirHardeningForTests } from "../../src/config/paths"; import type { OcxConfig } from "../../src/types"; diff --git a/tests/codex-integration/codex-pin-drain-projection.test.ts b/tests/codex-integration/codex-pin-drain-projection.test.ts index 1060c14e0d2..acd91702d75 100644 --- a/tests/codex-integration/codex-pin-drain-projection.test.ts +++ b/tests/codex-integration/codex-pin-drain-projection.test.ts @@ -4,10 +4,10 @@ import { join } from "node:path"; import { clearAccountNeedsReauth, clearAccountQuota, - handleCodexAuthAPI, markAccountNeedsReauth, updateAccountQuota, } from "../../src/codex/auth-api"; +import { handleCodexAuthAPI } from "../../src/codex/auth-api/routes"; import { pinnedCodexAccountId } from "../../src/codex/account-priority"; import { saveCodexAccountCredential } from "../../src/codex/account-store"; import { MAIN_CODEX_ACCOUNT_ID } from "../../src/codex/account-id"; diff --git a/tests/codex-integration/codex-priority-failback.test.ts b/tests/codex-integration/codex-priority-failback.test.ts index 1d971e2d425..7531b57aa1b 100644 --- a/tests/codex-integration/codex-priority-failback.test.ts +++ b/tests/codex-integration/codex-priority-failback.test.ts @@ -46,12 +46,12 @@ import { clearAccountNeedsReauth, clearAccountQuota, getAccountQuota, - handleCodexAuthAPI, isAccountNeedsReauth, parseUsageQuota, setAccountQuotaFromParsed, updateAccountQuota, } from "../../src/codex/auth-api"; +import { handleCodexAuthAPI } from "../../src/codex/auth-api/routes"; import { CODEX_UNKNOWN_USAGE_SCORE, isCodexQuotaExhausted } from "../../src/codex/quota"; import { setCodexAccountPriority } from "../../src/codex/account-priority"; import { MAIN_CODEX_ACCOUNT_ID } from "../../src/codex/main-account"; diff --git a/tests/codex-integration/codex-routing.test.ts b/tests/codex-integration/codex-routing.test.ts index 7d0d1b689b2..ce2f75438bc 100644 --- a/tests/codex-integration/codex-routing.test.ts +++ b/tests/codex-integration/codex-routing.test.ts @@ -41,12 +41,12 @@ import { clearAccountNeedsReauth, clearAccountQuota, getAccountQuota, - handleCodexAuthAPI, isAccountNeedsReauth, parseUsageQuota, setAccountQuotaFromParsed, updateAccountQuota, } from "../../src/codex/auth-api"; +import { handleCodexAuthAPI } from "../../src/codex/auth-api/routes"; import { CODEX_UNKNOWN_USAGE_SCORE, isCodexQuotaExhausted } from "../../src/codex/quota"; import { setCodexAccountPriority } from "../../src/codex/account-priority"; import { MAIN_CODEX_ACCOUNT_ID } from "../../src/codex/main-account"; diff --git a/tests/codex-integration/codex-service-manager-probe-hardening.test.ts b/tests/codex-integration/codex-service-manager-probe-hardening.test.ts index b209341df50..d7b8736ef75 100644 --- a/tests/codex-integration/codex-service-manager-probe-hardening.test.ts +++ b/tests/codex-integration/codex-service-manager-probe-hardening.test.ts @@ -11,7 +11,7 @@ import { } from "../../src/service-manager-probe"; import { inspectNativeCodexOwnership } from "../../src/integrations/native/ownership-preflight"; import { setTrustedWindowsSystemDirectoryResolverForTests } from "../../src/lib/windows-elevation"; -import { windowsWscript } from "../../src/service/windows-scheduler"; +import { windowsWscript } from "../../src/service/windows-wscript"; import { getDefaultConfig } from "../../src/config"; import { startServer } from "../../src/server"; import { removeTreeWithRetry } from "../helpers/remove-tree"; diff --git a/tests/codex-integration/codex-service-manager-probe.test.ts b/tests/codex-integration/codex-service-manager-probe.test.ts index 58e95da7d73..05751dad428 100644 --- a/tests/codex-integration/codex-service-manager-probe.test.ts +++ b/tests/codex-integration/codex-service-manager-probe.test.ts @@ -18,7 +18,7 @@ import { type RawProbeRunner, } from "../../src/service-manager-probe"; import { buildWindowsServiceScript, buildWindowsTaskXml } from "../../src/service/windows-taskxml"; -import { windowsWscript } from "../../src/service/windows-scheduler"; +import { windowsWscript } from "../../src/service/windows-wscript"; import { inspectNativeCodexOwnership } from "../../src/integrations/native/ownership-preflight"; import { setTrustedWindowsSystemDirectoryResolverForTests } from "../../src/lib/windows-elevation"; import { removeTreeWithRetry } from "../helpers/remove-tree"; diff --git a/tests/fixtures/import-cycle-baseline.json b/tests/fixtures/import-cycle-baseline.json new file mode 100644 index 00000000000..bd453aed1ec --- /dev/null +++ b/tests/fixtures/import-cycle-baseline.json @@ -0,0 +1,63 @@ +{ + "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", + "src/codex/auth-api/login-flow.ts", + "src/codex/auth-api/routes.ts", + "src/codex/catalog/aggregation.ts", + "src/codex/catalog/bundled.ts", + "src/codex/catalog/combo-member.ts", + "src/codex/catalog/configured-tiers.ts", + "src/codex/catalog/effort.ts", + "src/codex/catalog/gather-capture.ts", + "src/codex/catalog/metadata.ts", + "src/codex/catalog/model-hints.ts", + "src/codex/catalog/model-visibility.ts", + "src/codex/catalog/parsing.ts", + "src/codex/catalog/provider-fetch.ts", + "src/codex/catalog/provider-models.ts", + "src/codex/catalog/routed-gather.ts", + "src/codex/inject.ts", + "src/codex/inject/config-write-section.ts", + "src/codex/inject/restore.ts", + "src/codex/journal.ts", + "src/codex/main-account.ts", + "src/codex/native-profile-manager.ts", + "src/codex/native-profile-startup.ts", + "src/codex/subagent-model-fallback.ts", + "src/grok/inject.ts", + "src/integrations/native/ownership-preflight.ts", + "src/lib/state-store-registrations.ts", + "src/oauth/anthropic-model-quota.ts", + "src/oauth/anthropic-routing.ts", + "src/oauth/devin.ts", + "src/oauth/health.ts", + "src/oauth/index.ts", + "src/oauth/token-guardian.ts", + "src/providers/key-failover.ts", + "src/providers/kiro-account-state-disk.ts", + "src/providers/kiro-model-catalog.ts", + "src/providers/kiro-usage.ts", + "src/providers/quota.ts", + "src/providers/quota/account-cache.ts", + "src/providers/quota/anthropic-account-quota.ts", + "src/providers/quota/anthropic-cooldown-recovery.ts", + "src/providers/quota/antigravity.ts", + "src/providers/quota/kiro-account-probe.ts", + "src/providers/quota/report-cache.ts", + "src/providers/quota/vendor-probes-key.ts", + "src/providers/quota/vendor-probes-oauth.ts", + "src/router.ts", + "src/routing/capability.ts", + "src/routing/compatibility/assemble.ts", + "src/routing/evaluator.ts", + "src/routing/quota.ts", + "src/server/effort-policy.ts", + "src/service.ts", + "src/service/cli.ts", + "src/vision/eligibility.ts" + ] +} diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index bf1a2962e78..c0412f8b02e 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -675,7 +675,7 @@ "fastwire-characterization-wire.test.ts": "routing", "fastwire-observability.test.ts": "routing", "fastwire-response-authority.test.ts": "routing", "fastwire-policy.test.ts": "routing", "featherless-provider.test.ts": "providers", "fetch-header-timeout.test.ts": "server", - "file-size-ratchet.test.ts": "ci-workflows", "fixture-dir-uniqueness.test.ts": "ci-workflows", + "file-size-ratchet.test.ts": "ci-workflows", "fixture-dir-uniqueness.test.ts": "ci-workflows", "import-cycle-ratchet.test.ts": "ci-workflows", "flash-route-image-modalities.test.ts": "providers", "format-result.test.ts": "web-search", "forward-admission-separation.test.ts": "providers", "fresh-connection-optout.test.ts": "responses", "fulfill-video.test.ts": "videos", "gather-routed-models-single-flight.test.ts": "codex-integration", @@ -792,7 +792,7 @@ "lab-public-route-registry.test.ts": "lab", "lab-public-security-regressions.test.ts": "lab", "lab-public-surfaces.test.ts": "lab", "lab-public-wire-contract.test.ts": "lab", "lab-read-filter-validation.test.ts": "lab", "lab-read-surfaces.test.ts": "lab", - "launchd-repair.test.ts": "service", "legacy-shell-compat.test.ts": "responses", + "launchd-port-fallback.test.ts": "service", "launchd-repair.test.ts": "service", "legacy-shell-compat.test.ts": "responses", "live-call-bindings.test.ts": "server", "live-native-calls.test.ts": "server", "live-service-manager-guard.test.ts": "service", "linux-desktop-packaged-e2e.test.ts": "ci-workflows", "link-boundary.test.ts": "clients", diff --git a/tests/helpers/pool-reauth-cause.ts b/tests/helpers/pool-reauth-cause.ts index af75e1a102c..ddeb6d17d1d 100644 --- a/tests/helpers/pool-reauth-cause.ts +++ b/tests/helpers/pool-reauth-cause.ts @@ -1,10 +1,10 @@ import { expect, test } from "bun:test"; import { clearAccountNeedsReauth, - handleCodexAuthAPI, setAccountQuotaFromParsed, } from "../../src/codex/auth-api"; import { readCodexAccountRecord, saveCodexAccountCredential } from "../../src/codex/account-store"; +import { handleCodexAuthAPI } from "../../src/codex/auth-api/routes"; import { captureConfigGeneration } from "../../src/lib/state-store-sweeper"; import type { OcxConfig } from "../../src/types"; diff --git a/tests/helpers/reset-credit-consume-validation.ts b/tests/helpers/reset-credit-consume-validation.ts index 3f06e08dc8b..303c1ebbfcd 100644 --- a/tests/helpers/reset-credit-consume-validation.ts +++ b/tests/helpers/reset-credit-consume-validation.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { handleCodexAuthAPI } from "../../src/codex/auth-api"; +import { handleCodexAuthAPI } from "../../src/codex/auth-api/routes"; import { BOUNDED_BODY_MAX_BYTES } from "../../src/lib/bounded-body"; import type { OcxConfig } from "../../src/types"; diff --git a/tests/providers/api-key-selection-capture.test.ts b/tests/providers/api-key-selection-capture.test.ts index bd03c9f1e10..c8ad2050cc2 100644 --- a/tests/providers/api-key-selection-capture.test.ts +++ b/tests/providers/api-key-selection-capture.test.ts @@ -56,10 +56,12 @@ describe("selection capture dependency boundary", () => { expect(runtimeImports(readFileSync(repoPath("src/providers/api-key-selection-capture.ts"), "utf8"))).toEqual([]); }); - test("the router consumes capture without a direct import of the stateful selection module", () => { - const imports = runtimeImports(readFileSync(repoPath("src/router.ts"), "utf8")); - expect(imports).toContain("./providers/api-key-selection-capture"); - expect(imports).not.toContain("./providers/api-key-selection"); + test("the request-time resolver consumes capture without a direct import of the stateful selection module", () => { + // The resolver moved out of router.ts (it must not import the routing graph); this is the + // module on the request path now, so the boundary is asserted where the import lives. + const imports = runtimeImports(readFileSync(repoPath("src/providers/routed-config.ts"), "utf8")); + expect(imports).toContain("./api-key-selection-capture"); + expect(imports).not.toContain("./api-key-selection"); }); test("the boundary scanner distinguishes erased types from a runtime dependency", () => { diff --git a/tests/server/account-pool-management-api.test.ts b/tests/server/account-pool-management-api.test.ts index 0b04e62fb2d..c29579dc7b6 100644 --- a/tests/server/account-pool-management-api.test.ts +++ b/tests/server/account-pool-management-api.test.ts @@ -5,7 +5,7 @@ import { managementFetch as fetch } from "../helpers/management-auth"; import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { handleCodexAuthAPI } from "../../src/codex/auth-api"; +import { handleCodexAuthAPI } from "../../src/codex/auth-api/routes"; import { setPersistedConfigMutationBeforeCommitForTests } from "../../src/config/persisted-mutation"; import { loadConfig, saveConfig } from "../../src/config"; import { startServer } from "../../src/server"; diff --git a/tests/service/launchd-port-fallback.test.ts b/tests/service/launchd-port-fallback.test.ts new file mode 100644 index 00000000000..15b28078a59 --- /dev/null +++ b/tests/service/launchd-port-fallback.test.ts @@ -0,0 +1,131 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import * as fs from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getDefaultConfig } from "../../src/config"; +import { resolveServiceListenPort } from "../../src/service/definition"; +import { installedServiceListenPort } from "../../src/service/health"; +import { + expectedLaunchdCommand, + launchdListenPort, + probeLaunchdLoadState, + startLaunchd, +} from "../../src/service/launchd"; +import { plistPath } from "../../src/service/state"; +import { systemdListenPort, unitPath } from "../../src/service/systemd"; + +const foreignPort = 18221; +const configuredPort = 18222; +const plistPort = 18223; +const previousHome = process.env.OPENCODEX_HOME; +const previousBakedPort = process.env.OCX_BAKE_PORT; +let home: string; + +beforeEach(() => { + home = fs.mkdtempSync(join(tmpdir(), "ocx-launchd-port-")); + process.env.OPENCODEX_HOME = home; + delete process.env.OCX_BAKE_PORT; + fs.writeFileSync(join(home, "config.json"), JSON.stringify({ ...getDefaultConfig(), port: configuredPort })); +}); + +afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (previousBakedPort === undefined) delete process.env.OCX_BAKE_PORT; + else process.env.OCX_BAKE_PORT = previousBakedPort; + fs.rmSync(home, { recursive: true, force: true }); +}); + +/** + * Synthetic reads at the production artifact paths, never real service files. Using + * HOME alone cannot isolate these paths on every platform. The missing-plist case + * also proves the old generic fallback selects the foreign unit, so restoring it in + * either caller makes the regression fail. Manager calls use the existing seams. + */ +function withArtifacts(plist: string | null, body: () => void, parsedPort: number | null): void { + const launchdPath = plistPath(); + const systemdPath = unitPath(); + const original = fs.readFileSync; + const read = spyOn(fs, "readFileSync").mockImplementation(((path: any, ...args: any[]) => { + if (path === launchdPath) { + if (plist === null) throw Object.assign(new Error("missing fixture plist"), { code: "ENOENT" }); + return plist; + } + if (path === systemdPath) return `ExecStart=/fixture/ocx start --port ${foreignPort}\n`; + return original(path, ...args); + }) as typeof fs.readFileSync); + try { + expect(systemdListenPort()).toBe(foreignPort); + expect(resolveServiceListenPort()).toBe(configuredPort); + expect(launchdListenPort()).toBe(parsedPort); + expect(installedServiceListenPort()).toBe(parsedPort ?? foreignPort); + body(); + } finally { + read.mockRestore(); + } +} + +test("an unreadable plist cannot be accepted by a matching live job", () => { + let reads = 0; + const commands: string[] = []; + expect(() => startLaunchd({ + launchctl: () => ({ ok: false, stdout: "", stderr: "Load failed: 5", status: 5 }), + readPlist: () => { + reads += 1; + throw Object.assign(new Error("unreadable fixture plist"), { code: "EACCES" }); + }, + matches: command => { + commands.push(command); + return { loaded: true, matchesPlist: true }; + }, + })).toThrow(/missing or unreadable.*service repair/); + expect(reads).toBe(1); + expect(commands).toEqual([expectedLaunchdCommand(configuredPort)]); +}); + +for (const fixture of [ + { name: "missing plist ignores the foreign systemd port", plist: null, parsedPort: null, expectedPort: configuredPort }, + { name: "readable plist without a parseable port ignores the foreign systemd port", plist: "exec '/fixture/ocx' start --port invalid", parsedPort: null, expectedPort: configuredPort }, + { name: "installed plist takes precedence over config and foreign systemd", plist: `exec '/fixture/ocx' start --port ${plistPort}`, parsedPort: plistPort, expectedPort: plistPort }, +]) { + describe(fixture.name, () => { + test("the default load-state probe expects the launchd port", () => { + withArtifacts(fixture.plist, () => { + const expected = expectedLaunchdCommand(fixture.expectedPort); + expect(expected).toEndWith(`start --port ${fixture.expectedPort}`); + const calls: string[][] = []; + const verdict = probeLaunchdLoadState({ + uid: 501, + launchctl: args => { + calls.push(args); + return { ok: true, stdout: expected, stderr: "", status: 0 }; + }, + }); + expect(verdict).toEqual({ state: "loaded-current", domain: "gui/501" }); + expect(calls).toEqual([["print", "gui/501/com.opencodex.proxy"]]); + }, fixture.parsedPort); + }); + + test("an already-loaded start requires a readable plist and checks the launchd port", () => { + withArtifacts(fixture.plist, () => { + const commands: string[] = []; + const calls: string[][] = []; + const start = () => startLaunchd({ + launchctl: args => { + calls.push(args); + return { ok: false, stdout: "", stderr: "Load failed: 5", status: 5 }; + }, + matches: command => { + commands.push(command); + return { loaded: true, matchesPlist: true }; + }, + }); + if (fixture.plist === null) expect(start).toThrow(/service repair/); + else expect(start).not.toThrow(); + expect(commands).toEqual([expectedLaunchdCommand(fixture.expectedPort)]); + expect(commands[0]).toEndWith(`start --port ${fixture.expectedPort}`); + expect(calls).toEqual([["load", "-w", plistPath()]]); + }, fixture.parsedPort); + }); + }); +} diff --git a/tests/service/service.test.ts b/tests/service/service.test.ts index c7eb3daaa2b..b61ea1edf20 100644 --- a/tests/service/service.test.ts +++ b/tests/service/service.test.ts @@ -3546,10 +3546,12 @@ describe("launchctl load verification", () => { // A runLaunchctl RESULT, not a spawnSync result. const failedLoad = () => ({ ok: true, stdout: "", stderr: "Load failed: 5: Input/output error" }); const cleanLoad = () => ({ ok: true, stdout: "", stderr: "" }); + const readPlist = () => ""; test("returns without consulting launchd when the load is clean", () => { expect(() => startLaunchd({ launchctl: cleanLoad, + readPlist: () => { throw new Error("must not read a plist on a clean load"); }, matches: () => { throw new Error("must not be consulted on a clean load"); }, })).not.toThrow(); }); @@ -3563,7 +3565,7 @@ describe("launchctl load verification", () => { const log = spyOn(console, "log").mockImplementation(() => {}); try { expect(() => startLaunchd({ - launchctl: failedLoad, + launchctl: failedLoad, readPlist, matches: () => ({ loaded: true, matchesPlist: true }), })).not.toThrow(); } finally { @@ -3576,7 +3578,7 @@ describe("launchctl load verification", () => { const lines: string[] = []; const log = spyOn(console, "log").mockImplementation(m => { lines.push(String(m)); }); try { - startLaunchd({ launchctl: failedLoad, matches: () => ({ loaded: true, matchesPlist: true }) }); + startLaunchd({ launchctl: failedLoad, readPlist, matches: () => ({ loaded: true, matchesPlist: true }) }); } finally { log.mockRestore(); } @@ -3585,7 +3587,7 @@ describe("launchctl load verification", () => { test("throws with the bootout hint when the loaded job is stale", () => { expect(() => startLaunchd({ - launchctl: failedLoad, + launchctl: failedLoad, readPlist, matches: () => ({ loaded: true, matchesPlist: false }), })).toThrow(/bootout/); }); @@ -3594,7 +3596,7 @@ describe("launchctl load verification", () => { // The plist exists (this is an installed service) — reloading it is `repair`, // not a re-registration. expect(() => startLaunchd({ - launchctl: failedLoad, + launchctl: failedLoad, readPlist, matches: () => ({ loaded: false, matchesPlist: false }), })).toThrow(/service repair/); }); diff --git a/tests/service/standalone-service.test.ts b/tests/service/standalone-service.test.ts index 3f29882a0da..6daad0e4bf2 100644 --- a/tests/service/standalone-service.test.ts +++ b/tests/service/standalone-service.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { buildServiceShellCommand } from "../../src/service/health"; +import { buildServiceShellCommand } from "../../src/service/definition"; import { buildWinswXml } from "../../src/lib/winsw"; import { cliEntry } from "../../src/service/state"; import { buildWindowsServiceScript } from "../../src/service/windows-taskxml";