From 970cc7071a625b3bd635d27684cd389d1e48ee77 Mon Sep 17 00:00:00 2001 From: lcxhh Date: Thu, 15 Oct 2026 11:53:14 +0800 Subject: [PATCH 1/5] refactor: refresh small-cycle extraction onto current dev Replay the reviewed patch on dev 7640a942b35044c974e534b84d5a8049f4a8ae9a. Validated exact tree f1bc7625a617d07f6825ea8a0d5fd929d2142aff with focused regressions, typecheck, structure and privacy checks. No new production behavior is introduced by this refresh. --- scripts/test-layout/layout.json | 2 +- src/claude/desktop-first-party.ts | 2 +- src/claude/intercept/port-policy.ts | 30 ++ src/claude/intercept/runtime.ts | 21 +- src/client/machine-listener.ts | 4 +- src/client/runtime.ts | 1 + src/codex/account-label.ts | 2 +- src/codex/account-lifecycle.ts | 10 +- src/codex/app-server-processes.ts | 2 +- src/codex/catalog/parsing.ts | 102 +--- src/codex/native-profile-startup.ts | 2 +- src/codex/paths.ts | 87 +++- src/codex/refresh.ts | 4 +- src/codex/routing/health-store.ts | 2 +- src/codex/routing/thread-affinity.ts | 2 +- src/codex/subagent-model-fallback.ts | 2 +- src/grok/sync.ts | 2 +- src/providers/api-key-selection.ts | 2 +- src/providers/key-failover.ts | 2 +- src/providers/routed-config.ts | 332 +++++++++++++ src/router.ts | 326 +------------ src/routing/profile.ts | 2 +- src/server/claude-messages.ts | 437 ++---------------- src/server/effort-policy.ts | 2 +- src/server/management/config-routes.ts | 1 - .../management/cursor-integration-routes.ts | 2 +- .../management/native-integration-routes.ts | 7 +- src/server/messages-native.ts | 2 +- src/server/messages-shared.ts | 431 +++++++++++++++++ src/service.ts | 6 +- src/service/auth-preflight.ts | 71 +++ src/service/cli.ts | 6 +- src/service/definition.ts | 97 ++++ src/service/guards.ts | 62 +-- src/service/health.ts | 94 +--- src/service/launchd.ts | 10 +- src/service/repair.ts | 3 +- src/service/systemd.ts | 2 +- src/service/windows-guarded-manager.ts | 3 +- src/service/windows-ops.ts | 2 +- src/service/windows-scheduler.ts | 9 +- src/service/windows-taskxml.ts | 4 +- src/service/windows-wscript.ts | 14 + structure/runtime.md | 4 +- tests/clients/client-machine-listener.test.ts | 25 + .../codex-account-delete-atomicity.test.ts | 15 + .../codex-account-threshold-api.test.ts | 3 +- .../codex-account-threshold-auth.test.ts | 2 +- .../codex-account-threshold-routing.test.ts | 2 +- .../codex-integration/codex-auth-api.test.ts | 3 +- .../codex-auth-context.test.ts | 2 +- .../codex-credits-after-limit.test.ts | 3 +- .../codex-pin-drain-projection.test.ts | 2 +- .../codex-priority-failback.test.ts | 2 +- tests/codex-integration/codex-routing.test.ts | 2 +- ...ex-service-manager-probe-hardening.test.ts | 2 +- .../codex-service-manager-probe.test.ts | 2 +- tests/fixtures/test-layout-expected.json | 2 +- tests/helpers/pool-reauth-cause.ts | 2 +- .../reset-credit-consume-validation.ts | 2 +- .../api-key-selection-capture.test.ts | 10 +- .../account-pool-management-api.test.ts | 2 +- tests/service/launchd-port-fallback.test.ts | 110 +++++ tests/service/standalone-service.test.ts | 2 +- 64 files changed, 1360 insertions(+), 1045 deletions(-) create mode 100644 src/claude/intercept/port-policy.ts create mode 100644 src/providers/routed-config.ts create mode 100644 src/server/messages-shared.ts create mode 100644 src/service/auth-preflight.ts create mode 100644 src/service/definition.ts create mode 100644 src/service/windows-wscript.ts create mode 100644 tests/service/launchd-port-fallback.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 16227fe5cf1..7793e856bd8 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1098,7 +1098,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 7f0d3692243..9e1fe407d9e 100644 --- a/src/claude/intercept/runtime.ts +++ b/src/claude/intercept/runtime.ts @@ -12,6 +12,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"; @@ -41,25 +42,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 93771f26d9e..bf8af0766aa 100644 --- a/src/codex/account-lifecycle.ts +++ b/src/codex/account-lifecycle.ts @@ -11,7 +11,9 @@ import { clearAccountNeedsReauth } from "./account-runtime-state"; import { getMainChatgptAccountId, 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"; @@ -45,7 +47,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 0971e68375f..470b323678f 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 40eff2d49a9..89ba5f7344f 100644 --- a/src/providers/api-key-selection.ts +++ b/src/providers/api-key-selection.ts @@ -4,7 +4,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 564f03e19e8..b0a10776d21 100644 --- a/src/providers/key-failover.ts +++ b/src/providers/key-failover.ts @@ -10,7 +10,7 @@ */ import { commitProviderApiKeySelection, providerApiKeySelectionIsCurrent } from "./api-key-selection"; 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/routed-config.ts b/src/providers/routed-config.ts new file mode 100644 index 00000000000..74bc128d090 --- /dev/null +++ b/src/providers/routed-config.ts @@ -0,0 +1,332 @@ +/** + * 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 { 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 { + 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 0ea69b230a3..2e861098a99 100644 --- a/src/router.ts +++ b/src/router.ts @@ -14,25 +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 { 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, @@ -202,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. @@ -297,239 +212,10 @@ export function resetCompactionFallbackWarningsForTests(): void { compactionFallbackWarnings.clear(); } -function usableResolvedApiKey(apiKey: string | undefined): string | undefined { - 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 7bf4e6fa456..8f5fdc57e87 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 { @@ -59,10 +57,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"; @@ -71,7 +67,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, @@ -284,309 +279,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, @@ -702,35 +427,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" } @@ -1623,79 +1319,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 ce6de3b2d52..5437a2848c1 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -268,7 +268,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 9079441d769..c790f7c36f7 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 dab3432d8ba..41787319bfe 100644 --- a/src/server/messages-native.ts +++ b/src/server/messages-native.ts @@ -91,7 +91,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 5d896b2735b..1c262abbf8f 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", @@ -778,8 +781,9 @@ 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. + // Match the same launchd-only port choice as probeLaunchdLoadState above. const live = (deps.matches ?? launchdJobMatchesPlist)( - expectedLaunchdCommand(installedServiceListenPort()), + expectedLaunchdCommand(launchdListenPort() ?? resolveServiceListenPort()), ); if (live.loaded && live.matchesPlist) { console.log("ℹ️ service was already loaded from the current plist; nothing to do."); 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/runtime.md b/structure/runtime.md index 2a31215cb52..b6c4688de33 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -139,12 +139,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. | | `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`). 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 a missing plist with a foreign systemd port that differs from config, plus 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/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/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 210221f4d50..8c683903307 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -738,7 +738,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 ad6ccaccc47..a9523b3f391 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..972b4526eae --- /dev/null +++ b/tests/service/launchd-port-fallback.test.ts @@ -0,0 +1,110 @@ +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): 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(plist === null ? null : plistPort); + expect(installedServiceListenPort()).toBe(plist === null ? foreignPort : plistPort); + body(); + } finally { + read.mockRestore(); + } +} + +for (const fixture of [ + { name: "missing plist ignores the foreign systemd port", plist: null, expectedPort: configuredPort }, + { name: "installed plist takes precedence over config and foreign systemd", plist: `exec '/fixture/ocx' start --port ${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"]]); + }); + }); + + test("an already-loaded start checks the launchd port", () => { + withArtifacts(fixture.plist, () => { + const commands: string[] = []; + const calls: string[][] = []; + 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 }; + }, + }); + expect(commands).toEqual([expectedLaunchdCommand(fixture.expectedPort)]); + expect(commands[0]).toEndWith(`start --port ${fixture.expectedPort}`); + expect(calls).toEqual([["load", "-w", plistPath()]]); + }); + }); + }); +} 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"; From 5026d48939bf6f7dfcd0ae170536038ad81734bb Mon Sep 17 00:00:00 2001 From: GOAT Date: Thu, 15 Oct 2026 16:21:14 +0900 Subject: [PATCH 2/5] test: ratchet src/ import cycles and keep core entrypoints off them Adds scripts/import-cycles.ts (static value-import graph + Tarjan SCCs, same edge rules as the INV-LAB-01 walker) and tests/ci-workflows/import-cycle-ratchet.test.ts. The baseline lists the 59 modules still on a cycle after this PR; a new cyclic module fails, a resolved one must be dropped with --update, and src/config.ts, server/lifecycle.ts, responses/core.ts and management-api.ts must sit on no cycle. Recorded as INV-LAYER-01 for #6386. --- scripts/import-cycles.ts | 181 ++++++++++++++++++ scripts/test-layout/layout.json | 1 + structure/overview.md | 7 + .../ci-workflows/import-cycle-ratchet.test.ts | 122 ++++++++++++ tests/fixtures/import-cycle-baseline.json | 63 ++++++ tests/fixtures/test-layout-expected.json | 2 +- 6 files changed, 375 insertions(+), 1 deletion(-) create mode 100644 scripts/import-cycles.ts create mode 100644 tests/ci-workflows/import-cycle-ratchet.test.ts create mode 100644 tests/fixtures/import-cycle-baseline.json diff --git a/scripts/import-cycles.ts b/scripts/import-cycles.ts new file mode 100644 index 00000000000..19b66354313 --- /dev/null +++ b/scripts/import-cycles.ts @@ -0,0 +1,181 @@ +import { existsSync, 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 only, matched the same way as + * tests/lab/core-lab-boundary.test.ts: static imports, side-effect imports, and runtime re-exports. + * \`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 STATIC_IMPORT_RE = + /^\s*import\s+(?!type\b)[^;]*?from\s+["']([^"']+)["']|^\s*import\s+["']([^"']+)["']|^\s*export\s+(?!type\b)[^;]*?from\s+["']([^"']+)["']/gm; + +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) && !entry.name.endsWith(".d.ts")) out.push(abs); + } + return out; +} + +function resolveSpecifier(spec: string, fromFile: string): string | null { + if (!spec.startsWith(".")) return null; + const base = resolve(dirname(fromFile), spec); + const stem = base.replace(/\.(js|mjs)$/, ""); + for (const candidate of [base, stem + ".ts", stem + ".tsx", stem + ".mts", join(base, "index.ts")]) { + if (/\.(ts|tsx|mts)$/.test(candidate) && existsSync(candidate)) return candidate; + } + return null; +} + +/** Static value-import edges between source files in \`importer -> imported\` form. */ +export function staticImports(source: string): string[] { + const specs: string[] = []; + STATIC_IMPORT_RE.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = STATIC_IMPORT_RE.exec(source)) !== null) { + const spec = match[1] ?? match[2] ?? match[3]; + if (spec) specs.push(spec); + } + return specs; +} + +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"))) { + 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 7793e856bd8..e3b5049dc0a 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -906,6 +906,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", diff --git a/structure/overview.md b/structure/overview.md index 3a5e2eabdc7..58d60f29af9 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -131,6 +131,13 @@ 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). + 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/tests/ci-workflows/import-cycle-ratchet.test.ts b/tests/ci-workflows/import-cycle-ratchet.test.ts new file mode 100644 index 00000000000..3fd9e36aa54 --- /dev/null +++ b/tests/ci-workflows/import-cycle-ratchet.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { 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("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'); + 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/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 8c683903307..30e202a3c52 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -623,7 +623,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", From a0fec4e467f1cbabaae6d0480722465d5ed0114d Mon Sep 17 00:00:00 2001 From: lcxhh Date: Thu, 15 Oct 2026 20:02:13 +0800 Subject: [PATCH 3/5] fix: parse cycle edges with Bun and require readable launchd plist --- scripts/import-cycles.ts | 40 +++++++++---------- src/service/launchd.ts | 14 +++++-- structure/overview.md | 2 + structure/runtime.md | 2 +- .../ci-workflows/import-cycle-ratchet.test.ts | 36 ++++++++++++++++- tests/clients/client-link-runtime.test.ts | 5 ++- tests/service/launchd-port-fallback.test.ts | 39 +++++++++++++----- tests/service/service.test.ts | 10 +++-- 8 files changed, 107 insertions(+), 41 deletions(-) diff --git a/scripts/import-cycles.ts b/scripts/import-cycles.ts index 19b66354313..9c17e756e2c 100644 --- a/scripts/import-cycles.ts +++ b/scripts/import-cycles.ts @@ -1,4 +1,4 @@ -import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { readdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join, relative, resolve, sep } from "node:path"; /** @@ -10,8 +10,8 @@ import { dirname, join, relative, resolve, sep } from "node:path"; * 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 only, matched the same way as - * tests/lab/core-lab-boundary.test.ts: static imports, side-effect imports, and runtime re-exports. + * 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. * @@ -22,8 +22,8 @@ import { dirname, join, relative, resolve, sep } from "node:path"; export const BASELINE_REL = "tests/fixtures/import-cycle-baseline.json"; export const SOURCE_ROOT = "src"; -const STATIC_IMPORT_RE = - /^\s*import\s+(?!type\b)[^;]*?from\s+["']([^"']+)["']|^\s*import\s+["']([^"']+)["']|^\s*export\s+(?!type\b)[^;]*?from\s+["']([^"']+)["']/gm; +const importScanner = new Bun.Transpiler({ loader: "ts" }); +const tsxImportScanner = new Bun.Transpiler({ loader: "tsx" }); export type ImportGraph = Map; @@ -48,38 +48,36 @@ function listSources(dir: string): 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) && !entry.name.endsWith(".d.ts")) out.push(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; - const base = resolve(dirname(fromFile), spec); - const stem = base.replace(/\.(js|mjs)$/, ""); - for (const candidate of [base, stem + ".ts", stem + ".tsx", stem + ".mts", join(base, "index.ts")]) { - if (/\.(ts|tsx|mts)$/.test(candidate) && existsSync(candidate)) return candidate; + 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; } - return null; } /** Static value-import edges between source files in \`importer -> imported\` form. */ -export function staticImports(source: string): string[] { - const specs: string[] = []; - STATIC_IMPORT_RE.lastIndex = 0; - let match: RegExpExecArray | null; - while ((match = STATIC_IMPORT_RE.exec(source)) !== null) { - const spec = match[1] ?? match[2] ?? match[3]; - if (spec) specs.push(spec); - } - return specs; +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"))) { + for (const spec of staticImports(readFileSync(abs, "utf8"), abs.endsWith(".tsx"))) { const hit = resolveSpecifier(spec, abs); if (hit) targets.add(toRepoPath(root, hit)); } diff --git a/src/service/launchd.ts b/src/service/launchd.ts index 1c262abbf8f..d0fab709de8 100644 --- a/src/service/launchd.ts +++ b/src/service/launchd.ts @@ -772,6 +772,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(); @@ -781,17 +782,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(launchdListenPort() ?? resolveServiceListenPort()), + 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/structure/overview.md b/structure/overview.md index 58d60f29af9..ee5debe8c53 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -137,6 +137,8 @@ still cover the rule, which is a judgement only review makes. `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 diff --git a/structure/runtime.md b/structure/runtime.md index b6c4688de33..5c29daacdbb 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -144,7 +144,7 @@ does not perform OAuth, and runtime credential resolution rereads the owned sour | `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`. Launchd start and load-state checks select the installed plist port, falling back to the configured service port (including `OCX_BAKE_PORT`). 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 a missing plist with a foreign systemd port that differs from config, plus 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/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 index 3fd9e36aa54..f189708b445 100644 --- a/tests/ci-workflows/import-cycle-ratchet.test.ts +++ b/tests/ci-workflows/import-cycle-ratchet.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { basename, join } from "node:path"; import { BASELINE_REL, cyclicComponents, @@ -35,6 +35,39 @@ const CORE_OFF_CYCLE = [ 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";', @@ -70,6 +103,7 @@ describe("import-cycle ratchet: scanner", () => { 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"]); }); }); 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/service/launchd-port-fallback.test.ts b/tests/service/launchd-port-fallback.test.ts index 972b4526eae..15b28078a59 100644 --- a/tests/service/launchd-port-fallback.test.ts +++ b/tests/service/launchd-port-fallback.test.ts @@ -42,7 +42,7 @@ afterEach(() => { * 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): void { +function withArtifacts(plist: string | null, body: () => void, parsedPort: number | null): void { const launchdPath = plistPath(); const systemdPath = unitPath(); const original = fs.readFileSync; @@ -57,17 +57,36 @@ function withArtifacts(plist: string | null, body: () => void): void { try { expect(systemdListenPort()).toBe(foreignPort); expect(resolveServiceListenPort()).toBe(configuredPort); - expect(launchdListenPort()).toBe(plist === null ? null : plistPort); - expect(installedServiceListenPort()).toBe(plist === null ? foreignPort : plistPort); + 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, expectedPort: configuredPort }, - { name: "installed plist takes precedence over config and foreign systemd", plist: `exec '/fixture/ocx' start --port ${plistPort}`, expectedPort: plistPort }, + { 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", () => { @@ -84,14 +103,14 @@ for (const fixture of [ }); 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 checks the launchd port", () => { + test("an already-loaded start requires a readable plist and checks the launchd port", () => { withArtifacts(fixture.plist, () => { const commands: string[] = []; const calls: string[][] = []; - startLaunchd({ + const start = () => startLaunchd({ launchctl: args => { calls.push(args); return { ok: false, stdout: "", stderr: "Load failed: 5", status: 5 }; @@ -101,10 +120,12 @@ for (const fixture of [ 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/); }); From 1059ee2d0a691d6c1e1b7e2cf3b2f283aeeef559 Mon Sep 17 00:00:00 2001 From: lcxhh Date: Thu, 15 Oct 2026 18:21:54 -0700 Subject: [PATCH 4/5] chore: remove trailing blank line from import-cycle scanner --- scripts/import-cycles.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/import-cycles.ts b/scripts/import-cycles.ts index 9c17e756e2c..ee0cd144d5a 100644 --- a/scripts/import-cycles.ts +++ b/scripts/import-cycles.ts @@ -176,4 +176,3 @@ if (import.meta.main) { for (const component of components) console.log(component.length + ": " + component.join(" ")); } } - From 1811da01cdf9758c758476ee8f08067c9167feab Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 16 Oct 2026 13:19:57 +0900 Subject: [PATCH 5/5] fix(quota): defer account-row projection to keep import cycles bounded --- src/providers/quota.ts | 2 +- structure/providers-and-adapters.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) 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/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index e13b3892d62..34db4e53408 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.