diff --git a/AGENTS.md b/AGENTS.md index bca3985..5a0bd4e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,12 @@ npm run verify `npm run verify` is the default pre-release check. +## Module sizing + +- There is no hard max-lines or max-file-size rule in this repo. +- Split code only when it creates a real boundary: security policy, platform I/O, reusable primitives, or independently changing workflows. +- Prefer coherent feature modules over size-driven micro-files or catch-all monoliths. + ## Test fixtures Use only repository fixtures under: diff --git a/docs/development/ARCHITECTURE.md b/docs/development/ARCHITECTURE.md index 285dc28..18f4045 100644 --- a/docs/development/ARCHITECTURE.md +++ b/docs/development/ARCHITECTURE.md @@ -5,9 +5,9 @@ This plugin bridges OpenCode's OpenAI provider hooks to ChatGPT Codex backend en ## Runtime overview 1. OpenCode initializes plugin hooks (`index.ts`). -2. Config is resolved from `codex-config.json` + env overrides through `lib/config.ts` (barrel over `lib/config/*` split modules). -3. Auth loader selects a healthy account through `lib/storage.ts` + `lib/rotation.ts`, with storage migration/domain helpers in `lib/storage/*`. -4. `CodexAuthPlugin` wires modular auth/request helpers under `lib/codex-native/` (including split OAuth/request-transform/fetch helpers) and routes Codex backend requests. +2. Config is resolved from `codex-config.json` + env overrides through `lib/config.ts` (stable barrel over `lib/config/types.ts`, `lib/config/file.ts`, and `lib/config/resolve.ts`). +3. Auth loader selects a healthy account through `lib/storage.ts` + `lib/rotation.ts`, with storage normalization/migration helpers consolidated in `lib/storage/auth-state.ts`. +4. `CodexAuthPlugin` wires focused auth/request helpers under `lib/codex-native/` and routes Codex backend requests. 5. Failures (`429`, refresh/auth) trigger cooldown/disable semantics and retry orchestration (`lib/fetch-orchestrator.ts`). ## Key modules @@ -28,13 +28,11 @@ This plugin bridges OpenCode's OpenAI provider hooks to ChatGPT Codex backend en - `lib/codex-native/auth-menu-quotas.ts` - auth-menu quota snapshot refresh + cooldown handling - `lib/codex-native/oauth-auth-methods.ts`, `lib/codex-native/oauth-persistence.ts`, `lib/codex-native/oauth-utils.ts`, `lib/codex-native/oauth-server.ts` - - browser/headless OAuth method flows, token persistence, OAuth primitives, callback server lifecycle -- `lib/codex-native/oauth-server-debug.ts`, `lib/codex-native/oauth-server-network.ts`, `lib/codex-native/oauth-server-types.ts` - - OAuth server diagnostics, loopback binding policy, and lifecycle typing used by `oauth-server.ts` -- `lib/codex-native/request-transform-pipeline.ts`, `lib/codex-native/request-transform.ts`, `lib/codex-native/request-transform-model.ts`, `lib/codex-native/request-transform-payload.ts`, `lib/codex-native/request-transform-instructions.ts`, `lib/codex-native/chat-hooks.ts`, `lib/codex-native/session-messages.ts` - - request/body transform pipeline and chat hook behavior (params/headers/compaction) -- `lib/codex-native/catalog-sync.ts`, `lib/codex-native/catalog-auth.ts` - - model-catalog bootstrap and refresh wiring + - browser/headless OAuth method flows, token persistence, OAuth primitives, callback server lifecycle, loopback binding policy, and debug logging +- `lib/codex-native/request-transform-model.ts`, `lib/codex-native/request-transform-model-service-tier.ts`, `lib/codex-native/request-transform-payload.ts`, `lib/codex-native/request-transform-payload-helpers.ts`, `lib/codex-native/chat-hooks.ts`, `lib/codex-native/session-messages.ts` + - request/body transform ownership split by model defaults, service-tier resolution, payload rewrites, and chat hook behavior +- `lib/codex-native/catalog-sync.ts` + - model-catalog bootstrap, auth selection for bootstrap, and per-auth refresh wiring - `lib/codex-native/collaboration.ts` - plan-mode, orchestrator, and subagent collaboration instruction injection - `lib/codex-native/originator.ts` @@ -43,8 +41,8 @@ This plugin bridges OpenCode's OpenAI provider hooks to ChatGPT Codex backend en - system browser launch for OAuth callback flow - `lib/codex-native/session-affinity-state.ts`, `lib/codex-native/rate-limit-snapshots.ts`, `lib/codex-native/request-routing.ts` - session affinity persistence, rate-limit snapshot persistence, outbound URL guard/rewrite -- `lib/storage.ts`, `lib/storage/domain-state.ts`, `lib/storage/migration.ts` - - lock-guarded auth store IO, migration normalization, domain/account invariants, explicit legacy transfer +- `lib/storage.ts`, `lib/storage/auth-state.ts` + - lock-guarded auth store IO, migration normalization, domain/account invariants, and explicit legacy transfer - `lib/rotation.ts` - `sticky`, `hybrid`, `round_robin` account selection - `lib/fetch-orchestrator.ts` @@ -67,8 +65,8 @@ This plugin bridges OpenCode's OpenAI provider hooks to ChatGPT Codex backend en - custom personality file generation with enforced core assistant contract - `lib/personalities.ts` - custom personality resolution from lowercase `personalities/` directories -- `lib/ui/auth-menu.ts`, `lib/ui/auth-menu-runner.ts` - - TTY account manager UI +- `lib/ui/auth-menu.ts`, `lib/ui/tty.ts` + - TTY account manager UI and reusable terminal primitives - `lib/accounts-tools.ts` - tool handler logic for `codex-status`, `codex-switch-accounts`, `codex-toggle-account`, `codex-remove-account` - `lib/codex-status-tool.ts`, `lib/codex-status-storage.ts`, `lib/codex-status-ui.ts` @@ -83,8 +81,8 @@ This plugin bridges OpenCode's OpenAI provider hooks to ChatGPT Codex backend en - quota percentage threshold warnings and cooldown triggers - `lib/cache-io.ts`, `lib/cache-lock.ts`, `lib/codex-cache-layout.ts` - shared cache IO primitives, lock helpers, and cache directory layout -- `lib/config.ts`, `lib/config/types.ts`, `lib/config/validation.ts`, `lib/config/parse.ts`, `lib/config/io.ts`, `lib/config/resolve.ts` - - config typing, parsing, defaults, IO, and getter resolution through a stable top-level barrel +- `lib/config.ts`, `lib/config/types.ts`, `lib/config/file.ts`, `lib/config/resolve.ts` + - config typing, file parsing/validation/default-file IO, and getter resolution through a stable top-level barrel - `lib/persona-tool.ts`, `lib/personality-skill.ts` - persona generation logic and `personality-builder` skill bundle management - `lib/identity.ts` diff --git a/lib/codex-native/auth-menu-flow.ts b/lib/codex-native/auth-menu-flow.ts index 7e48a90..e9d28ad 100644 --- a/lib/codex-native/auth-menu-flow.ts +++ b/lib/codex-native/auth-menu-flow.ts @@ -10,8 +10,8 @@ import { shouldOfferLegacyTransfer } from "../storage.js" import type { OpenAIAuthMode } from "../types.js" -import { runAuthMenuOnce } from "../ui/auth-menu-runner.js" -import { shouldUseColor } from "../ui/tty/ansi.js" +import { runAuthMenuOnce } from "../ui/auth-menu.js" +import { shouldUseColor } from "../ui/tty.js" import { buildAuthMenuAccounts, ensureAccountAuthTypes, diff --git a/lib/codex-native/catalog-auth.ts b/lib/codex-native/catalog-auth.ts deleted file mode 100644 index 50cfc7d..0000000 --- a/lib/codex-native/catalog-auth.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { selectAccount } from "../rotation.js" -import { getOpenAIOAuthDomain, loadAuthStorage } from "../storage.js" -import type { OpenAIAuthMode, RotationStrategy } from "../types.js" - -export async function selectCatalogAuthCandidate( - authMode: OpenAIAuthMode, - pidOffsetEnabled: boolean, - rotationStrategy?: RotationStrategy -): Promise<{ accessToken?: string; accountId?: string }> { - try { - const auth = await loadAuthStorage() - const domain = getOpenAIOAuthDomain(auth, authMode) - if (!domain) { - return {} - } - const now = Date.now() - - const selected = selectAccount({ - accounts: domain.accounts, - strategy: rotationStrategy ?? domain.strategy, - activeIdentityKey: domain.activeIdentityKey, - now, - stickyPidOffset: pidOffsetEnabled - }) - - if (!selected?.access) { - return { accountId: selected?.accountId } - } - - const expires = selected.expires - if (typeof expires !== "number" || !Number.isFinite(expires) || expires <= now) { - return { accountId: selected.accountId } - } - - return { - accessToken: selected.access, - accountId: selected.accountId - } - } catch (error) { - if (error instanceof Error) { - // best-effort catalog auth selection - } - return {} - } -} diff --git a/lib/codex-native/catalog-sync.ts b/lib/codex-native/catalog-sync.ts index 16f6400..12ff996 100644 --- a/lib/codex-native/catalog-sync.ts +++ b/lib/codex-native/catalog-sync.ts @@ -1,7 +1,8 @@ import type { Logger } from "../logger.js" import { getCodexModelCatalog, type CodexModelInfo } from "../model-catalog.js" +import { selectAccount } from "../rotation.js" +import { getOpenAIOAuthDomain, loadAuthStorage } from "../storage.js" import type { OpenAIAuthMode, RotationStrategy } from "../types.js" -import { selectCatalogAuthCandidate } from "./catalog-auth.js" import { resolveCatalogScopeKey } from "./openai-loader-fetch-state.js" type CatalogHeaders = { @@ -12,6 +13,48 @@ type CatalogHeaders = { openaiBeta?: string } +export async function selectCatalogAuthCandidate( + authMode: OpenAIAuthMode, + pidOffsetEnabled: boolean, + rotationStrategy?: RotationStrategy +): Promise<{ accessToken?: string; accountId?: string }> { + try { + const auth = await loadAuthStorage() + const domain = getOpenAIOAuthDomain(auth, authMode) + if (!domain) { + return {} + } + const now = Date.now() + + const selected = selectAccount({ + accounts: domain.accounts, + strategy: rotationStrategy ?? domain.strategy, + activeIdentityKey: domain.activeIdentityKey, + now, + stickyPidOffset: pidOffsetEnabled + }) + + if (!selected?.access) { + return { accountId: selected?.accountId } + } + + const expires = selected.expires + if (typeof expires !== "number" || !Number.isFinite(expires) || expires <= now) { + return { accountId: selected.accountId } + } + + return { + accessToken: selected.access, + accountId: selected.accountId + } + } catch (error) { + if (error instanceof Error) { + // best-effort catalog auth selection + } + return {} + } +} + export async function initializeCatalogSync(input: { authMode: OpenAIAuthMode pidOffsetEnabled: boolean diff --git a/lib/codex-native/chat-hooks.ts b/lib/codex-native/chat-hooks.ts index bf86836..d3d5c14 100644 --- a/lib/codex-native/chat-hooks.ts +++ b/lib/codex-native/chat-hooks.ts @@ -12,7 +12,7 @@ import { getModelVerbosityOverride, getVariantLookupCandidates, resolvePersonalityForModel -} from "./request-transform.js" +} from "./request-transform-model.js" import { resolveServiceTierForModel } from "./request-transform-model-service-tier.js" import { resolveRequestUserAgent } from "./client-identity.js" import { resolveCodexOriginator } from "./originator.js" diff --git a/lib/codex-native/oauth-server-debug.ts b/lib/codex-native/oauth-server-debug.ts deleted file mode 100644 index 7959e51..0000000 --- a/lib/codex-native/oauth-server-debug.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { appendFileSync, chmodSync, mkdirSync, renameSync, statSync, unlinkSync } from "node:fs" - -import { isFsErrorCode } from "../cache-io.js" - -const DEFAULT_DEBUG_LOG_MAX_BYTES = 1_000_000 -const REDACTED = "[redacted]" -const REDACTED_DEBUG_META_KEY_FRAGMENTS = [ - "token", - "secret", - "password", - "authorization", - "cookie", - "id_token", - "refresh_token", - "access_token", - "authorization_code", - "auth_code", - "code_verifier", - "pkce", - "verifier" -] - -function shouldRedactDebugMetaKey(key: string): boolean { - const lower = key.trim().toLowerCase() - if (!lower) return false - return REDACTED_DEBUG_META_KEY_FRAGMENTS.some((fragment) => lower.includes(fragment)) -} - -export function sanitizeDebugMeta(value: unknown): unknown { - if (typeof value === "string") { - return value - .replace( - /\b(access_token|refresh_token|id_token|authorization_code|auth_code|code_verifier|pkce_verifier)=([^\s&]+)/gi, - `$1=${REDACTED}` - ) - .replace( - /"(access_token|refresh_token|id_token|authorization_code|auth_code|code_verifier|pkce_verifier)"\s*:\s*"[^"]*"/gi, - `"$1":"${REDACTED}"` - ) - .replace( - /([?&])(code|state|access_token|refresh_token|id_token|code_verifier|pkce_verifier)=([^&]+)/gi, - (_match, prefix, key) => { - return `${prefix}${key}=${REDACTED}` - } - ) - } - if (Array.isArray(value)) return value.map((item) => sanitizeDebugMeta(item)) - if (!value || typeof value !== "object") return value - - const out: Record = {} - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = shouldRedactDebugMetaKey(key) ? REDACTED : sanitizeDebugMeta(entry) - } - return out -} - -export function resolveDebugLogMaxBytes(): number { - const raw = process.env.CODEX_AUTH_DEBUG_MAX_BYTES - if (!raw) return DEFAULT_DEBUG_LOG_MAX_BYTES - const parsed = Number(raw) - if (!Number.isFinite(parsed)) return DEFAULT_DEBUG_LOG_MAX_BYTES - return Math.max(16_384, Math.floor(parsed)) -} - -export function rotateDebugLogIfNeeded(debugLogFile: string, maxBytes: number): void { - try { - const stat = statSync(debugLogFile) - if (stat.size < maxBytes) return - const rotatedPath = `${debugLogFile}.1` - try { - unlinkSync(rotatedPath) - } catch (error) { - if (!isFsErrorCode(error, "ENOENT")) { - // ignore missing previous rotation file - } - // ignore missing previous rotation file - } - renameSync(debugLogFile, rotatedPath) - } catch (error) { - if (!isFsErrorCode(error, "ENOENT")) { - // ignore when file does not exist or cannot be inspected - } - // ignore when file does not exist or cannot be inspected - } -} - -export function appendDebugLine(input: { - debugLogDir: string - debugLogFile: string - debugLogMaxBytes: number - line: string -}): void { - try { - mkdirSync(input.debugLogDir, { recursive: true, mode: 0o700 }) - rotateDebugLogIfNeeded(input.debugLogFile, input.debugLogMaxBytes) - appendFileSync(input.debugLogFile, `${input.line}\n`, { encoding: "utf8", mode: 0o600 }) - chmodSync(input.debugLogFile, 0o600) - } catch (error) { - if (error instanceof Error) { - // best effort file logging - } - // best effort file logging - } -} diff --git a/lib/codex-native/oauth-server-network.ts b/lib/codex-native/oauth-server-network.ts deleted file mode 100644 index 42acc42..0000000 --- a/lib/codex-native/oauth-server-network.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { isFsErrorCode } from "../cache-io.js" - -export function isLoopbackRemoteAddress(remoteAddress: string | undefined): boolean { - if (!remoteAddress) return false - const normalized = remoteAddress.split("%")[0]?.toLowerCase() - if (!normalized) return false - if (normalized === "::1") return true - if (normalized.startsWith("127.")) return true - if (normalized.startsWith("::ffff:127.")) return true - return false -} - -export function resolveListenHosts(loopbackHost: string): string[] { - const normalized = loopbackHost.trim().toLowerCase() - if (normalized === "localhost") { - return ["localhost", "127.0.0.1", "::1"] - } - return [loopbackHost] -} - -export function shouldRetryListenWithFallback(error: unknown): boolean { - return ( - isFsErrorCode(error, "EADDRNOTAVAIL") || isFsErrorCode(error, "EAFNOSUPPORT") || isFsErrorCode(error, "ENOTFOUND") - ) -} - -export function rewriteCallbackUriHost(callbackUri: string, host: string): string { - try { - const url = new URL(callbackUri) - url.hostname = host - return url.toString() - } catch (error) { - if (error instanceof Error) { - // fallback to configured callback URI when URL parsing fails - } - return callbackUri - } -} diff --git a/lib/codex-native/oauth-server-types.ts b/lib/codex-native/oauth-server-types.ts deleted file mode 100644 index d14276a..0000000 --- a/lib/codex-native/oauth-server-types.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { OpenAIAuthMode } from "../types.js" - -export type OAuthServerStopReason = "success" | "error" | "other" - -export type OAuthServerControllerInput = { - port: number - loopbackHost: string - callbackOrigin: string - callbackUri: string - callbackPath: string - callbackTimeoutMs: number - debugLogDir?: string - debugLogFile?: string - buildOAuthErrorHtml: (error: string) => string - buildOAuthSuccessHtml: (mode: "native" | "codex") => string - composeCodexSuccessRedirectUrl: (tokens: TTokens) => string - exchangeCodeForTokens: (code: string, redirectUri: string, pkce: TPkce) => Promise -} - -export type PendingOAuth = { - pkce: TPkce - state: string - authMode: OpenAIAuthMode - resolve: (tokens: TTokens) => void - reject: (error: Error) => void -} diff --git a/lib/codex-native/oauth-server.ts b/lib/codex-native/oauth-server.ts index 1ce98ea..c72dda6 100644 --- a/lib/codex-native/oauth-server.ts +++ b/lib/codex-native/oauth-server.ts @@ -1,18 +1,167 @@ import http from "node:http" +import { appendFileSync, chmodSync, mkdirSync, renameSync, statSync, unlinkSync } from "node:fs" import path from "node:path" +import { isFsErrorCode } from "../cache-io.js" import type { OpenAIAuthMode } from "../types.js" import { defaultCodexPluginLogsPath } from "../paths.js" -import { appendDebugLine, resolveDebugLogMaxBytes, sanitizeDebugMeta } from "./oauth-server-debug.js" -import { - isLoopbackRemoteAddress, - resolveListenHosts, - rewriteCallbackUriHost, - shouldRetryListenWithFallback -} from "./oauth-server-network.js" -import type { OAuthServerControllerInput, OAuthServerStopReason, PendingOAuth } from "./oauth-server-types.js" - -export { isLoopbackRemoteAddress } from "./oauth-server-network.js" +const DEFAULT_DEBUG_LOG_MAX_BYTES = 1_000_000 +const REDACTED = "[redacted]" +const REDACTED_DEBUG_META_KEY_FRAGMENTS = [ + "token", + "secret", + "password", + "authorization", + "cookie", + "id_token", + "refresh_token", + "access_token", + "authorization_code", + "auth_code", + "code_verifier", + "pkce", + "verifier" +] + +export type OAuthServerStopReason = "success" | "error" | "other" + +type OAuthServerControllerInput = { + port: number + loopbackHost: string + callbackOrigin: string + callbackUri: string + callbackPath: string + callbackTimeoutMs: number + debugLogDir?: string + debugLogFile?: string + buildOAuthErrorHtml: (error: string) => string + buildOAuthSuccessHtml: (mode: "native" | "codex") => string + composeCodexSuccessRedirectUrl: (tokens: TTokens) => string + exchangeCodeForTokens: (code: string, redirectUri: string, pkce: TPkce) => Promise +} + +type PendingOAuth = { + pkce: TPkce + state: string + authMode: OpenAIAuthMode + resolve: (tokens: TTokens) => void + reject: (error: Error) => void +} + +function shouldRedactDebugMetaKey(key: string): boolean { + const lower = key.trim().toLowerCase() + if (!lower) return false + return REDACTED_DEBUG_META_KEY_FRAGMENTS.some((fragment) => lower.includes(fragment)) +} + +function sanitizeDebugMeta(value: unknown): unknown { + if (typeof value === "string") { + return value + .replace( + /\b(access_token|refresh_token|id_token|authorization_code|auth_code|code_verifier|pkce_verifier)=([^\s&]+)/gi, + `$1=${REDACTED}` + ) + .replace( + /"(access_token|refresh_token|id_token|authorization_code|auth_code|code_verifier|pkce_verifier)"\s*:\s*"[^"]*"/gi, + `"$1":"${REDACTED}"` + ) + .replace( + /([?&])(code|state|access_token|refresh_token|id_token|code_verifier|pkce_verifier)=([^&]+)/gi, + (_match, prefix, key) => `${prefix}${key}=${REDACTED}` + ) + } + if (Array.isArray(value)) return value.map((item) => sanitizeDebugMeta(item)) + if (!value || typeof value !== "object") return value + + const out: Record = {} + for (const [key, entry] of Object.entries(value as Record)) { + out[key] = shouldRedactDebugMetaKey(key) ? REDACTED : sanitizeDebugMeta(entry) + } + return out +} + +function resolveDebugLogMaxBytes(): number { + const raw = process.env.CODEX_AUTH_DEBUG_MAX_BYTES + if (!raw) return DEFAULT_DEBUG_LOG_MAX_BYTES + const parsed = Number(raw) + if (!Number.isFinite(parsed)) return DEFAULT_DEBUG_LOG_MAX_BYTES + return Math.max(16_384, Math.floor(parsed)) +} + +function rotateDebugLogIfNeeded(debugLogFile: string, maxBytes: number): void { + try { + const stat = statSync(debugLogFile) + if (stat.size < maxBytes) return + const rotatedPath = `${debugLogFile}.1` + try { + unlinkSync(rotatedPath) + } catch (error) { + if (!isFsErrorCode(error, "ENOENT")) { + // ignore missing previous rotation file + } + } + renameSync(debugLogFile, rotatedPath) + } catch (error) { + if (!isFsErrorCode(error, "ENOENT")) { + // ignore when file does not exist or cannot be inspected + } + } +} + +function appendDebugLine(input: { + debugLogDir: string + debugLogFile: string + debugLogMaxBytes: number + line: string +}): void { + try { + mkdirSync(input.debugLogDir, { recursive: true, mode: 0o700 }) + rotateDebugLogIfNeeded(input.debugLogFile, input.debugLogMaxBytes) + appendFileSync(input.debugLogFile, `${input.line}\n`, { encoding: "utf8", mode: 0o600 }) + chmodSync(input.debugLogFile, 0o600) + } catch (error) { + if (error instanceof Error) { + // best effort file logging + } + } +} + +export function isLoopbackRemoteAddress(remoteAddress: string | undefined): boolean { + if (!remoteAddress) return false + const normalized = remoteAddress.split("%")[0]?.toLowerCase() + if (!normalized) return false + if (normalized === "::1") return true + if (normalized.startsWith("127.")) return true + if (normalized.startsWith("::ffff:127.")) return true + return false +} + +function resolveListenHosts(loopbackHost: string): string[] { + const normalized = loopbackHost.trim().toLowerCase() + if (normalized === "localhost") { + return ["localhost", "127.0.0.1", "::1"] + } + return [loopbackHost] +} + +function shouldRetryListenWithFallback(error: unknown): boolean { + return ( + isFsErrorCode(error, "EADDRNOTAVAIL") || isFsErrorCode(error, "EAFNOSUPPORT") || isFsErrorCode(error, "ENOTFOUND") + ) +} + +function rewriteCallbackUriHost(callbackUri: string, host: string): string { + try { + const url = new URL(callbackUri) + url.hostname = host + return url.toString() + } catch (error) { + if (error instanceof Error) { + // fallback to configured callback URI when URL parsing fails + } + return callbackUri + } +} export function createOAuthServerController( input: OAuthServerControllerInput diff --git a/lib/codex-native/openai-loader-fetch.ts b/lib/codex-native/openai-loader-fetch.ts index 60cfcd0..c69a9cf 100644 --- a/lib/codex-native/openai-loader-fetch.ts +++ b/lib/codex-native/openai-loader-fetch.ts @@ -12,7 +12,10 @@ import { resolveCodexOriginator } from "./originator.js" import { buildProjectPromptCacheKey } from "../prompt-cache-key.js" import { persistRateLimitSnapshotFromResponse } from "./rate-limit-snapshots.js" import { assertAllowedOutboundUrl, rewriteUrl } from "./request-routing.js" -import { type OutboundRequestPayloadTransformResult, transformOutboundRequestPayload } from "./request-transform.js" +import { + type OutboundRequestPayloadTransformResult, + transformOutboundRequestPayload +} from "./request-transform-payload.js" import type { SessionAffinityRuntimeState } from "./session-affinity-state.js" import { scheduleQuotaRefresh } from "./openai-loader-fetch-quota.js" import { diff --git a/lib/codex-native/request-transform-gpt54-limits.ts b/lib/codex-native/request-transform-gpt54-limits.ts deleted file mode 100644 index df93776..0000000 --- a/lib/codex-native/request-transform-gpt54-limits.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { getModelLookupCandidates } from "./request-transform-model.js" -import { asString } from "./request-transform-shared.js" - -const GPT_5_4_MAX_CONTEXT_WINDOW = 1_050_000 -const GPT_5_4_MAX_OUTPUT_TOKENS = 128_000 -const GPT_5_4_MAX_PRACTICAL_INPUT_TOKENS = GPT_5_4_MAX_CONTEXT_WINDOW - GPT_5_4_MAX_OUTPUT_TOKENS - -export function applyGpt54LongContextClampsToPayload(payload: Record): boolean { - const modelSlug = asString(payload.model) - if (!modelSlug) return false - - const modelCandidates = getModelLookupCandidates({ - id: modelSlug, - api: { id: modelSlug } - }) - const isGpt54 = modelCandidates.some((candidate) => candidate.trim().toLowerCase().startsWith("gpt-5.4")) - if (!isGpt54) return false - - let changed = false - - const contextWindow = asFiniteNumber(payload.model_context_window) - if (contextWindow !== undefined && contextWindow > GPT_5_4_MAX_CONTEXT_WINDOW) { - payload.model_context_window = GPT_5_4_MAX_CONTEXT_WINDOW - changed = true - } - - const effectiveContextWindowMax = Math.min( - GPT_5_4_MAX_CONTEXT_WINDOW, - asFiniteNumber(payload.model_context_window) ?? GPT_5_4_MAX_CONTEXT_WINDOW - ) - const autoCompactMax = Math.max( - 0, - Math.min(GPT_5_4_MAX_PRACTICAL_INPUT_TOKENS, effectiveContextWindowMax - GPT_5_4_MAX_OUTPUT_TOKENS) - ) - const autoCompact = asFiniteNumber(payload.model_auto_compact_token_limit) - if (autoCompact !== undefined && autoCompact > autoCompactMax) { - payload.model_auto_compact_token_limit = autoCompactMax - changed = true - } - - const maxOutputTokens = asFiniteNumber(payload.max_output_tokens) - if (maxOutputTokens !== undefined && maxOutputTokens > GPT_5_4_MAX_OUTPUT_TOKENS) { - payload.max_output_tokens = GPT_5_4_MAX_OUTPUT_TOKENS - changed = true - } - - return changed -} - -function asFiniteNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined -} diff --git a/lib/codex-native/request-transform-model-service-tier.ts b/lib/codex-native/request-transform-model-service-tier.ts index 71b24d1..077f109 100644 --- a/lib/codex-native/request-transform-model-service-tier.ts +++ b/lib/codex-native/request-transform-model-service-tier.ts @@ -1,6 +1,13 @@ import type { BehaviorSettings, ServiceTierOption } from "../config.js" import { isRecord } from "../util.js" -import { asString, EFFORT_SUFFIX_REGEX } from "./request-transform-shared.js" + +const EFFORT_SUFFIX_REGEX = /-(none|minimal|low|medium|high|xhigh)$/i + +function asString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined + const trimmed = value.trim() + return trimmed ? trimmed : undefined +} function resolveCaseInsensitiveEntry(entries: Record | undefined, candidate: string): T | undefined { if (!entries) return undefined diff --git a/lib/codex-native/request-transform-model.ts b/lib/codex-native/request-transform-model.ts index 8e64901..f2721f4 100644 --- a/lib/codex-native/request-transform-model.ts +++ b/lib/codex-native/request-transform-model.ts @@ -1,15 +1,53 @@ import type { BehaviorSettings, PersonalityOption } from "../config.js" import type { CodexModelInfo } from "../model-catalog.js" import { isRecord } from "../util.js" -import { - asString, - asStringArray, - EFFORT_SUFFIX_REGEX, - mergeUnique, - normalizeReasoningSummaryOption, - normalizeTextVerbosity, - normalizeVerbositySetting -} from "./request-transform-shared.js" + +const EFFORT_SUFFIX_REGEX = /-(none|minimal|low|medium|high|xhigh)$/i + +function asString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined + const trimmed = value.trim() + return trimmed ? trimmed : undefined +} + +function asStringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined + return value.filter((item): item is string => typeof item === "string" && item.trim().length > 0) +} + +function normalizeReasoningSummaryOption(value: unknown): "auto" | "concise" | "detailed" | undefined { + const normalized = asString(value)?.toLowerCase() + if (!normalized || normalized === "none") return undefined + if (normalized === "auto" || normalized === "concise" || normalized === "detailed") return normalized + return undefined +} + +function normalizeTextVerbosity(value: unknown): "low" | "medium" | "high" | undefined { + const normalized = asString(value)?.toLowerCase() + if (!normalized) return undefined + if (normalized === "low" || normalized === "medium" || normalized === "high") return normalized + return undefined +} + +function normalizeVerbositySetting(value: unknown): "default" | "low" | "medium" | "high" | undefined { + const normalized = asString(value)?.toLowerCase() + if (!normalized) return undefined + if (normalized === "default" || normalized === "low" || normalized === "medium" || normalized === "high") { + return normalized + } + return undefined +} + +function mergeUnique(values: string[]): string[] { + const out: string[] = [] + const seen = new Set() + for (const value of values) { + if (seen.has(value)) continue + seen.add(value) + out.push(value) + } + return out +} type ChatParamsOutput = { temperature: number diff --git a/lib/codex-native/request-transform-payload-helpers.ts b/lib/codex-native/request-transform-payload-helpers.ts index 383b940..136540f 100644 --- a/lib/codex-native/request-transform-payload-helpers.ts +++ b/lib/codex-native/request-transform-payload-helpers.ts @@ -1,5 +1,10 @@ import { isRecord } from "../util.js" -import { asString } from "./request-transform-shared.js" + +function asString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined + const trimmed = value.trim() + return trimmed ? trimmed : undefined +} export type TransformReason = | "disabled" diff --git a/lib/codex-native/request-transform-payload.ts b/lib/codex-native/request-transform-payload.ts index f47c509..9068efc 100644 --- a/lib/codex-native/request-transform-payload.ts +++ b/lib/codex-native/request-transform-payload.ts @@ -3,14 +3,12 @@ import type { CodexModelInfo } from "../model-catalog.js" import { getRuntimeDefaultsForModel, resolveInstructionsForModel } from "../model-catalog.js" import { sanitizeRequestPayloadForCompat } from "../compat-sanitizer.js" import { isRecord } from "../util.js" -import { applyGpt54LongContextClampsToPayload } from "./request-transform-gpt54-limits.js" import { findCatalogModelForCandidates, getModelLookupCandidates, resolvePersonalityForModel } from "./request-transform-model.js" import { getRequestBodyVariantCandidates } from "./request-transform-model-service-tier.js" -import { asString, asStringArray } from "./request-transform-shared.js" import { type CompatSanitizerTransformResult, type DeveloperRoleRemapTransformResult, @@ -22,6 +20,67 @@ import { stripReasoningReplayFromPayload } from "./request-transform-payload-helpers.js" +function asString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined + const trimmed = value.trim() + return trimmed ? trimmed : undefined +} + +function asStringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined + return value.filter((item): item is string => typeof item === "string" && item.trim().length > 0) +} + +const GPT_5_4_MAX_CONTEXT_WINDOW = 1_050_000 +const GPT_5_4_MAX_OUTPUT_TOKENS = 128_000 +const GPT_5_4_MAX_PRACTICAL_INPUT_TOKENS = GPT_5_4_MAX_CONTEXT_WINDOW - GPT_5_4_MAX_OUTPUT_TOKENS + +function asFiniteNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined +} + +export function applyGpt54LongContextClampsToPayload(payload: Record): boolean { + const modelSlug = asString(payload.model) + if (!modelSlug) return false + + const modelCandidates = getModelLookupCandidates({ + id: modelSlug, + api: { id: modelSlug } + }) + const isGpt54 = modelCandidates.some((candidate) => candidate.trim().toLowerCase().startsWith("gpt-5.4")) + if (!isGpt54) return false + + let changed = false + + const contextWindow = asFiniteNumber(payload.model_context_window) + if (contextWindow !== undefined && contextWindow > GPT_5_4_MAX_CONTEXT_WINDOW) { + payload.model_context_window = GPT_5_4_MAX_CONTEXT_WINDOW + changed = true + } + + const effectiveContextWindowMax = Math.min( + GPT_5_4_MAX_CONTEXT_WINDOW, + asFiniteNumber(payload.model_context_window) ?? GPT_5_4_MAX_CONTEXT_WINDOW + ) + const autoCompactMax = Math.max( + 0, + Math.min(GPT_5_4_MAX_PRACTICAL_INPUT_TOKENS, effectiveContextWindowMax - GPT_5_4_MAX_OUTPUT_TOKENS) + ) + const autoCompact = asFiniteNumber(payload.model_auto_compact_token_limit) + if (autoCompact !== undefined && autoCompact > autoCompactMax) { + payload.model_auto_compact_token_limit = autoCompactMax + changed = true + } + + const maxOutputTokens = asFiniteNumber(payload.max_output_tokens) + if (maxOutputTokens !== undefined && maxOutputTokens > GPT_5_4_MAX_OUTPUT_TOKENS) { + payload.max_output_tokens = GPT_5_4_MAX_OUTPUT_TOKENS + changed = true + } + + return changed +} + type OutboundRequestPayloadTransformInput = { request: Request stripReasoningReplayEnabled: boolean diff --git a/lib/codex-native/request-transform-shared.ts b/lib/codex-native/request-transform-shared.ts deleted file mode 100644 index eb435e9..0000000 --- a/lib/codex-native/request-transform-shared.ts +++ /dev/null @@ -1,46 +0,0 @@ -export const EFFORT_SUFFIX_REGEX = /-(none|minimal|low|medium|high|xhigh)$/i - -export function asString(value: unknown): string | undefined { - if (typeof value !== "string") return undefined - const trimmed = value.trim() - return trimmed ? trimmed : undefined -} - -export function asStringArray(value: unknown): string[] | undefined { - if (!Array.isArray(value)) return undefined - return value.filter((item): item is string => typeof item === "string" && item.trim().length > 0) -} - -export function normalizeReasoningSummaryOption(value: unknown): "auto" | "concise" | "detailed" | undefined { - const normalized = asString(value)?.toLowerCase() - if (!normalized || normalized === "none") return undefined - if (normalized === "auto" || normalized === "concise" || normalized === "detailed") return normalized - return undefined -} - -export function normalizeTextVerbosity(value: unknown): "low" | "medium" | "high" | undefined { - const normalized = asString(value)?.toLowerCase() - if (!normalized) return undefined - if (normalized === "low" || normalized === "medium" || normalized === "high") return normalized - return undefined -} - -export function normalizeVerbositySetting(value: unknown): "default" | "low" | "medium" | "high" | undefined { - const normalized = asString(value)?.toLowerCase() - if (!normalized) return undefined - if (normalized === "default" || normalized === "low" || normalized === "medium" || normalized === "high") { - return normalized - } - return undefined -} - -export function mergeUnique(values: string[]): string[] { - const out: string[] = [] - const seen = new Set() - for (const value of values) { - if (seen.has(value)) continue - seen.add(value) - out.push(value) - } - return out -} diff --git a/lib/codex-native/request-transform.ts b/lib/codex-native/request-transform.ts deleted file mode 100644 index b99ebc5..0000000 --- a/lib/codex-native/request-transform.ts +++ /dev/null @@ -1,27 +0,0 @@ -export { - applyCodexRuntimeDefaultsToParams, - findCatalogModelForCandidates, - getModelLookupCandidates, - getModelThinkingSummariesOverride, - getModelVerbosityEnabledOverride, - getModelVerbosityOverride, - getVariantLookupCandidates, - resolvePersonalityForModel -} from "./request-transform-model.js" - -export { - getModelServiceTierOverride, - getRequestBodyVariantCandidates, - resolveServiceTierForModel -} from "./request-transform-model-service-tier.js" - -export { - applyPromptCacheKeyOverrideToRequest, - remapDeveloperMessagesToUserOnRequest, - sanitizeOutboundRequestIfNeeded, - stripStaleCatalogScopedDefaultsFromRequest, - stripReasoningReplayFromRequest, - transformOutboundRequestPayload, - type ServiceTierTransformResult, - type OutboundRequestPayloadTransformResult -} from "./request-transform-payload.js" diff --git a/lib/codex-status-tool.ts b/lib/codex-status-tool.ts index 3d488fc..4a618ac 100644 --- a/lib/codex-status-tool.ts +++ b/lib/codex-status-tool.ts @@ -2,7 +2,7 @@ import { loadAuthStorage } from "./storage.js" import { loadSnapshots } from "./codex-status-storage.js" import { renderDashboard, type StatusRenderStyle } from "./codex-status-ui.js" import { defaultAuthPath, defaultSnapshotsPath } from "./paths.js" -import { shouldUseColor } from "./ui/tty/ansi.js" +import { shouldUseColor } from "./ui/tty.js" /** * Returns a human-readable string summarizing the status of all Codex accounts. diff --git a/lib/codex-status-ui.ts b/lib/codex-status-ui.ts index cee07d2..da3b46b 100644 --- a/lib/codex-status-ui.ts +++ b/lib/codex-status-ui.ts @@ -1,5 +1,5 @@ import type { CodexRateLimitSnapshot, AccountRecord, CodexLimit } from "./types.js" -import { ANSI } from "./ui/tty/ansi.js" +import { ANSI } from "./ui/tty.js" const FULL_BLOCK = "█" const EMPTY_BLOCK = "░" diff --git a/lib/config.ts b/lib/config.ts index c5f3bac..38fdef5 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -14,12 +14,10 @@ export { type VerbosityOption } from "./config/types.js" -export { type ConfigValidationResult, validateConfigFileObject } from "./config/validation.js" - -export { cloneBehaviorSettings } from "./config/behavior-settings.js" - export { - buildResolvedBehaviorSettings, + type ConfigValidationResult, + ensureDefaultConfigFile, + loadConfigFile, normalizePersonalityOption, normalizeServiceTierOption, normalizeVerbosityOption, @@ -30,17 +28,15 @@ export { parsePromptCacheKeyStrategy, parseRotationStrategy, parseRuntimeMode, - parseSpoofMode -} from "./config/parse.js" - -export { - ensureDefaultConfigFile, - loadConfigFile, + parseSpoofMode, resolveDefaultConfigPath, - type EnsureDefaultConfigFileResult -} from "./config/io.js" + type EnsureDefaultConfigFileResult, + validateConfigFileObject +} from "./config/file.js" export { + buildResolvedBehaviorSettings, + cloneBehaviorSettings, getBehaviorSettings, getCodexCompactionOverrideEnabled, getCollaborationProfileEnabled, diff --git a/lib/config/behavior-settings.ts b/lib/config/behavior-settings.ts deleted file mode 100644 index fe65c87..0000000 --- a/lib/config/behavior-settings.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { BehaviorSettings } from "./types.js" - -function cloneBehaviorOverride>(input: T | undefined): T | undefined { - if (!input) return undefined - return { ...input } -} - -export function cloneBehaviorSettings(input: BehaviorSettings | undefined): BehaviorSettings | undefined { - if (!input) return undefined - return { - ...(input.global - ? { - global: cloneBehaviorOverride(input.global) - } - : {}), - perModel: input.perModel - ? Object.fromEntries( - Object.entries(input.perModel).map(([key, value]) => [ - key, - { - ...cloneBehaviorOverride(value), - ...(value.variants - ? { - variants: Object.fromEntries( - Object.entries(value.variants).map(([variantKey, variantValue]) => [ - variantKey, - cloneBehaviorOverride(variantValue) ?? {} - ]) - ) - } - : {}) - } - ]) - ) - : undefined - } -} diff --git a/lib/config/file.ts b/lib/config/file.ts new file mode 100644 index 0000000..74be14b --- /dev/null +++ b/lib/config/file.ts @@ -0,0 +1,575 @@ +import fs from "node:fs" +import fsPromises from "node:fs/promises" +import os from "node:os" +import path from "node:path" + +import { isRecord } from "../util.js" +import type { RotationStrategy } from "../types.js" +import { + CONFIG_FILE, + DEFAULT_CODEX_CONFIG_TEMPLATE, + type BehaviorSettings, + type ModelConfigOverride, + type PersonalityOption, + type PluginConfig, + type PluginRuntimeMode, + type PromptCacheKeyStrategy, + type ServiceTierOption, + type VerbosityOption +} from "./types.js" + +export type ConfigValidationResult = { + valid: boolean + issues: string[] +} + +export type EnsureDefaultConfigFileResult = { + filePath: string + created: boolean +} + +type ModelBehaviorSettings = { + personality?: PersonalityOption + thinkingSummaries?: boolean + verbosityEnabled?: boolean + verbosity?: VerbosityOption + serviceTier?: ServiceTierOption +} + +function describeValueType(value: unknown): string { + if (Array.isArray(value)) return "array" + if (value === null) return "null" + return typeof value +} + +function pushValidationIssue( + issues: string[], + input: { + path: string + expected: string + actual: unknown + } +): void { + issues.push(`${input.path}: expected ${input.expected}, got ${describeValueType(input.actual)}`) +} + +export function parseEnvBoolean(value: string | undefined): boolean | undefined { + if (value === undefined) return undefined + const normalized = value.trim().toLowerCase() + if (normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on") return true + if (normalized === "0" || normalized === "false" || normalized === "no" || normalized === "off") return false + return undefined +} + +export function parseEnvNumber(value: string | undefined): number | undefined { + if (value === undefined) return undefined + if (value.trim().length === 0) return undefined + const n = Number(value) + return Number.isFinite(n) ? n : undefined +} + +function stripJsonComments(raw: string): string { + let out = "" + let inString = false + let escaped = false + let inLineComment = false + let inBlockComment = false + + for (let index = 0; index < raw.length; index += 1) { + const ch = raw[index] + const next = raw[index + 1] + + if (inLineComment) { + if (ch === "\n" || ch === "\r") { + inLineComment = false + out += ch + } + continue + } + + if (inBlockComment) { + if (ch === "*" && next === "/") { + inBlockComment = false + index += 1 + continue + } + if (ch === "\n" || ch === "\r") { + out += ch + } + continue + } + + if (inString) { + out += ch + if (escaped) { + escaped = false + } else if (ch === "\\") { + escaped = true + } else if (ch === '"') { + inString = false + } + continue + } + + if (ch === '"') { + inString = true + out += ch + continue + } + + if (ch === "/" && next === "/") { + inLineComment = true + index += 1 + continue + } + + if (ch === "/" && next === "*") { + inBlockComment = true + index += 1 + continue + } + + out += ch + } + + return out +} + +function normalizeModelBehaviorSettings(raw: unknown): ModelBehaviorSettings | undefined { + if (!isRecord(raw)) return undefined + const out: ModelBehaviorSettings = {} + + const personality = normalizePersonalityOption(raw.personality) + if (personality) out.personality = personality + + if (typeof raw.thinkingSummaries === "boolean") out.thinkingSummaries = raw.thinkingSummaries + if (typeof raw.verbosityEnabled === "boolean") out.verbosityEnabled = raw.verbosityEnabled + + const verbosity = normalizeVerbosityOption(raw.verbosity) + if (verbosity) out.verbosity = verbosity + + const serviceTier = normalizeServiceTierOption(raw.serviceTier) + if (serviceTier) out.serviceTier = serviceTier + + if ( + !out.personality && + out.thinkingSummaries === undefined && + out.verbosityEnabled === undefined && + out.verbosity === undefined && + out.serviceTier === undefined + ) { + return undefined + } + + return out +} + +function normalizeModelConfigOverride(raw: unknown): ModelConfigOverride | undefined { + if (!isRecord(raw)) return undefined + + const modelBehavior = normalizeModelBehaviorSettings(raw) + const rawVariants = isRecord(raw.variants) ? raw.variants : undefined + + let variants: ModelConfigOverride["variants"] | undefined + if (rawVariants) { + const variantMap: NonNullable = {} + for (const [variantName, value] of Object.entries(rawVariants)) { + const normalized = normalizeModelBehaviorSettings(value) + if (!normalized) continue + variantMap[variantName] = { + ...(normalized.personality ? { personality: normalized.personality } : {}), + ...(normalized.thinkingSummaries !== undefined ? { thinkingSummaries: normalized.thinkingSummaries } : {}), + ...(normalized.verbosityEnabled !== undefined ? { verbosityEnabled: normalized.verbosityEnabled } : {}), + ...(normalized.verbosity ? { verbosity: normalized.verbosity } : {}), + ...(normalized.serviceTier ? { serviceTier: normalized.serviceTier } : {}) + } + } + if (Object.keys(variantMap).length > 0) { + variants = variantMap + } + } + + if (!modelBehavior && !variants) { + return undefined + } + + return { + ...(modelBehavior?.personality ? { personality: modelBehavior.personality } : {}), + ...(modelBehavior?.thinkingSummaries !== undefined ? { thinkingSummaries: modelBehavior.thinkingSummaries } : {}), + ...(modelBehavior?.verbosityEnabled !== undefined ? { verbosityEnabled: modelBehavior.verbosityEnabled } : {}), + ...(modelBehavior?.verbosity ? { verbosity: modelBehavior.verbosity } : {}), + ...(modelBehavior?.serviceTier ? { serviceTier: modelBehavior.serviceTier } : {}), + ...(variants ? { variants } : {}) + } +} + +function normalizeNewBehaviorSections(raw: Record): BehaviorSettings | undefined { + const global = normalizeModelBehaviorSettings(raw.global) + const perModelRaw = isRecord(raw.perModel) ? raw.perModel : undefined + + let perModel: BehaviorSettings["perModel"] | undefined + if (perModelRaw) { + const modelMap: NonNullable = {} + for (const [modelName, value] of Object.entries(perModelRaw)) { + const normalized = normalizeModelConfigOverride(value) + if (!normalized) continue + modelMap[modelName] = normalized + } + if (Object.keys(modelMap).length > 0) { + perModel = modelMap + } + } + + if (!global && !perModel) return undefined + + return { + ...(global ? { global } : {}), + ...(perModel ? { perModel } : {}) + } +} + +function validateModelBehaviorShape(value: unknown, pathPrefix: string, issues: string[]): void { + if (!isRecord(value)) { + pushValidationIssue(issues, { path: pathPrefix, expected: "object", actual: value }) + return + } + + if ("personality" in value && typeof value.personality !== "string") { + pushValidationIssue(issues, { path: `${pathPrefix}.personality`, expected: "string", actual: value.personality }) + } + if ("thinkingSummaries" in value && typeof value.thinkingSummaries !== "boolean") { + pushValidationIssue(issues, { + path: `${pathPrefix}.thinkingSummaries`, + expected: "boolean", + actual: value.thinkingSummaries + }) + } + if ("verbosityEnabled" in value && typeof value.verbosityEnabled !== "boolean") { + pushValidationIssue(issues, { + path: `${pathPrefix}.verbosityEnabled`, + expected: "boolean", + actual: value.verbosityEnabled + }) + } + if ("verbosity" in value) { + const verbosity = value.verbosity + const normalized = typeof verbosity === "string" ? verbosity.trim().toLowerCase() : "" + if (!(normalized === "default" || normalized === "low" || normalized === "medium" || normalized === "high")) { + pushValidationIssue(issues, { + path: `${pathPrefix}.verbosity`, + expected: '"default" | "low" | "medium" | "high"', + actual: verbosity + }) + } + } + if ("serviceTier" in value) { + const serviceTier = value.serviceTier + const normalized = typeof serviceTier === "string" ? serviceTier.trim().toLowerCase() : "" + if (!(normalized === "default" || normalized === "priority" || normalized === "flex")) { + pushValidationIssue(issues, { + path: `${pathPrefix}.serviceTier`, + expected: '"default" | "priority" | "flex"', + actual: serviceTier + }) + } + } +} + +export function parseConfigJsonWithComments(raw: string): unknown { + return JSON.parse(stripJsonComments(raw)) as unknown +} + +export function normalizePersonalityOption(value: unknown): PersonalityOption | undefined { + if (typeof value !== "string") return undefined + const normalized = value.trim().toLowerCase() + if (!normalized) return undefined + if (normalized.includes("/") || normalized.includes("\\") || normalized.includes("..")) { + return undefined + } + return normalized +} + +export function parseSpoofMode(value: unknown): PluginRuntimeMode | undefined { + if (typeof value !== "string") return undefined + const normalized = value.trim().toLowerCase() + if (normalized === "native") return "native" + if (normalized === "codex") return "codex" + return undefined +} + +export function parseRuntimeMode(value: unknown): PluginRuntimeMode | undefined { + if (typeof value !== "string") return undefined + const normalized = value.trim().toLowerCase() + if (normalized === "native") return "native" + if (normalized === "codex") return "codex" + return undefined +} + +export function parseRotationStrategy(value: unknown): RotationStrategy | undefined { + if (typeof value !== "string") return undefined + const normalized = value.trim().toLowerCase() + if (normalized === "sticky" || normalized === "hybrid" || normalized === "round_robin") { + return normalized + } + return undefined +} + +export function parsePromptCacheKeyStrategy(value: unknown): PromptCacheKeyStrategy | undefined { + if (typeof value !== "string") return undefined + const normalized = value.trim().toLowerCase() + if (normalized === "default" || normalized === "project") return normalized + return undefined +} + +export function normalizeVerbosityOption(value: unknown): VerbosityOption | undefined { + if (typeof value !== "string") return undefined + const normalized = value.trim().toLowerCase() + if (normalized === "default" || normalized === "low" || normalized === "medium" || normalized === "high") { + return normalized + } + return undefined +} + +export function normalizeServiceTierOption(value: unknown): ServiceTierOption | undefined { + if (typeof value !== "string") return undefined + const normalized = value.trim().toLowerCase() + if (normalized === "default" || normalized === "priority" || normalized === "flex") { + return normalized + } + return undefined +} + +export function validateConfigFileObject(raw: unknown): ConfigValidationResult { + const issues: string[] = [] + if (!isRecord(raw)) { + pushValidationIssue(issues, { path: "$", expected: "object", actual: raw }) + return { valid: false, issues } + } + + if ("$schema" in raw && typeof raw.$schema !== "string") { + pushValidationIssue(issues, { path: "$schema", expected: "string", actual: raw.$schema }) + } + if ("debug" in raw && typeof raw.debug !== "boolean") { + pushValidationIssue(issues, { path: "debug", expected: "boolean", actual: raw.debug }) + } + if ("quiet" in raw && typeof raw.quiet !== "boolean") { + pushValidationIssue(issues, { path: "quiet", expected: "boolean", actual: raw.quiet }) + } + + if ("refreshAhead" in raw) { + if (!isRecord(raw.refreshAhead)) { + pushValidationIssue(issues, { path: "refreshAhead", expected: "object", actual: raw.refreshAhead }) + } else { + if ("enabled" in raw.refreshAhead && typeof raw.refreshAhead.enabled !== "boolean") { + pushValidationIssue(issues, { + path: "refreshAhead.enabled", + expected: "boolean", + actual: raw.refreshAhead.enabled + }) + } + if ( + "bufferMs" in raw.refreshAhead && + (typeof raw.refreshAhead.bufferMs !== "number" || !Number.isFinite(raw.refreshAhead.bufferMs)) + ) { + pushValidationIssue(issues, { + path: "refreshAhead.bufferMs", + expected: "number", + actual: raw.refreshAhead.bufferMs + }) + } + } + } + + if ("runtime" in raw) { + if (!isRecord(raw.runtime)) { + pushValidationIssue(issues, { path: "runtime", expected: "object", actual: raw.runtime }) + } else { + const runtime = raw.runtime + const enumChecks: Array<{ field: string; allowed: string[] }> = [ + { field: "mode", allowed: ["native", "codex"] }, + { field: "rotationStrategy", allowed: ["sticky", "hybrid", "round_robin"] }, + { field: "promptCacheKeyStrategy", allowed: ["default", "project"] } + ] + for (const check of enumChecks) { + const value = runtime[check.field] + if (value === undefined) continue + const normalized = typeof value === "string" ? value.trim().toLowerCase() : "" + if (!check.allowed.includes(normalized)) { + pushValidationIssue(issues, { + path: `runtime.${check.field}`, + expected: check.allowed.map((item) => `"${item}"`).join(" | "), + actual: value + }) + } + } + + const boolFields = [ + "sanitizeInputs", + "developerMessagesToUser", + "codexCompactionOverride", + "headerSnapshots", + "headerSnapshotBodies", + "headerTransformDebug", + "pidOffset", + "collaborationProfile", + "orchestratorSubagents" + ] + for (const field of boolFields) { + if (field in runtime && typeof runtime[field] !== "boolean") { + pushValidationIssue(issues, { + path: `runtime.${field}`, + expected: "boolean", + actual: runtime[field] + }) + } + } + } + } + + if ("global" in raw) { + validateModelBehaviorShape(raw.global, "global", issues) + } + + if ("perModel" in raw) { + if (!isRecord(raw.perModel)) { + pushValidationIssue(issues, { path: "perModel", expected: "object", actual: raw.perModel }) + } else { + for (const [modelName, modelValue] of Object.entries(raw.perModel)) { + validateModelBehaviorShape(modelValue, `perModel.${modelName}`, issues) + if (!isRecord(modelValue) || !("variants" in modelValue)) continue + + const variants = modelValue.variants + if (!isRecord(variants)) { + pushValidationIssue(issues, { + path: `perModel.${modelName}.variants`, + expected: "object", + actual: variants + }) + continue + } + for (const [variantName, variantValue] of Object.entries(variants)) { + validateModelBehaviorShape(variantValue, `perModel.${modelName}.variants.${variantName}`, issues) + } + } + } + } + + return { valid: issues.length === 0, issues } +} + +export function parseConfigFileObject(raw: unknown): Partial { + if (!isRecord(raw)) return {} + + const behaviorSettings = normalizeNewBehaviorSections(raw) + const personalityFromBehavior = behaviorSettings?.global?.personality + const runtime = isRecord(raw.runtime) ? raw.runtime : undefined + + const debug = typeof raw.debug === "boolean" ? raw.debug : undefined + const proactiveRefresh = + isRecord(raw.refreshAhead) && typeof raw.refreshAhead.enabled === "boolean" ? raw.refreshAhead.enabled : undefined + const proactiveRefreshBufferMs = + isRecord(raw.refreshAhead) && typeof raw.refreshAhead.bufferMs === "number" ? raw.refreshAhead.bufferMs : undefined + const quietMode = typeof raw.quiet === "boolean" ? raw.quiet : undefined + const mode = parseRuntimeMode(runtime?.mode) + const rotationStrategy = parseRotationStrategy(runtime?.rotationStrategy) + const promptCacheKeyStrategy = parsePromptCacheKeyStrategy(runtime?.promptCacheKeyStrategy) + const spoofMode = mode === "native" ? "native" : mode === "codex" ? "codex" : undefined + const compatInputSanitizer = typeof runtime?.sanitizeInputs === "boolean" ? runtime.sanitizeInputs : undefined + const remapDeveloperMessagesToUser = + typeof runtime?.developerMessagesToUser === "boolean" ? runtime.developerMessagesToUser : undefined + const codexCompactionOverride = + typeof runtime?.codexCompactionOverride === "boolean" ? runtime.codexCompactionOverride : undefined + const headerSnapshots = typeof runtime?.headerSnapshots === "boolean" ? runtime.headerSnapshots : undefined + const headerSnapshotBodies = + typeof runtime?.headerSnapshotBodies === "boolean" ? runtime.headerSnapshotBodies : undefined + const headerTransformDebug = + typeof runtime?.headerTransformDebug === "boolean" ? runtime.headerTransformDebug : undefined + const pidOffsetEnabled = typeof runtime?.pidOffset === "boolean" ? runtime.pidOffset : undefined + const collaborationProfileEnabled = + typeof runtime?.collaborationProfile === "boolean" ? runtime.collaborationProfile : undefined + const orchestratorSubagentsEnabled = + typeof runtime?.orchestratorSubagents === "boolean" ? runtime.orchestratorSubagents : undefined + + return { + debug, + proactiveRefresh, + proactiveRefreshBufferMs, + quiet: quietMode, + quietMode, + pidOffsetEnabled, + personality: personalityFromBehavior, + mode, + rotationStrategy, + promptCacheKeyStrategy, + spoofMode, + compatInputSanitizer, + remapDeveloperMessagesToUser, + codexCompactionOverride, + headerSnapshots, + headerSnapshotBodies, + headerTransformDebug, + collaborationProfile: collaborationProfileEnabled, + collaborationProfileEnabled, + orchestratorSubagents: orchestratorSubagentsEnabled, + orchestratorSubagentsEnabled, + behaviorSettings + } +} + +export function resolveDefaultConfigPath(env: Record): string { + const xdgRoot = env.XDG_CONFIG_HOME?.trim() + if (xdgRoot) { + return path.join(xdgRoot, "opencode", CONFIG_FILE) + } + return path.join(os.homedir(), ".config", "opencode", CONFIG_FILE) +} + +export async function ensureDefaultConfigFile( + input: { env?: Record; filePath?: string; overwrite?: boolean } = {} +): Promise { + const env = input.env ?? process.env + const filePath = input.filePath ?? resolveDefaultConfigPath(env) + const overwrite = input.overwrite === true + + if (!overwrite && fs.existsSync(filePath)) { + return { filePath, created: false } + } + + await fsPromises.mkdir(path.dirname(filePath), { recursive: true }) + await fsPromises.writeFile(filePath, DEFAULT_CODEX_CONFIG_TEMPLATE, { encoding: "utf8", mode: 0o600 }) + try { + await fsPromises.chmod(filePath, 0o600) + } catch (error) { + if (error instanceof Error) { + // best-effort permission hardening + } + } + return { filePath, created: true } +} + +export function loadConfigFile( + input: { env?: Record; filePath?: string } = {} +): Partial { + const env = input.env ?? process.env + const explicitPath = input.filePath ?? env.OPENCODE_OPENAI_MULTI_CONFIG_PATH?.trim() + const candidates = explicitPath ? [explicitPath] : [resolveDefaultConfigPath(env)] + + for (const filePath of candidates) { + if (!filePath || !fs.existsSync(filePath)) continue + try { + const raw = fs.readFileSync(filePath, "utf8") + const parsed = parseConfigJsonWithComments(raw) + const validation = validateConfigFileObject(parsed) + if (!validation.valid) { + console.warn(`[opencode-codex-auth] Invalid codex-config at ${filePath}. ${validation.issues.join("; ")}`) + continue + } + return parseConfigFileObject(parsed) + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + console.warn(`[opencode-codex-auth] Failed to read codex-config at ${filePath}. ${detail}`) + } + } + + return {} +} diff --git a/lib/config/io.ts b/lib/config/io.ts deleted file mode 100644 index 2985221..0000000 --- a/lib/config/io.ts +++ /dev/null @@ -1,76 +0,0 @@ -import fs from "node:fs" -import fsPromises from "node:fs/promises" -import os from "node:os" -import path from "node:path" -import { CONFIG_FILE, DEFAULT_CODEX_CONFIG_TEMPLATE, type PluginConfig } from "./types.js" -import { parseConfigFileObject, parseConfigJsonWithComments } from "./parse.js" -import { validateConfigFileObject } from "./validation.js" - -export function resolveDefaultConfigPath(env: Record): string { - const xdgRoot = env.XDG_CONFIG_HOME?.trim() - if (xdgRoot) { - return path.join(xdgRoot, "opencode", CONFIG_FILE) - } - return path.join(os.homedir(), ".config", "opencode", CONFIG_FILE) -} - -export type EnsureDefaultConfigFileResult = { - filePath: string - created: boolean -} - -export async function ensureDefaultConfigFile( - input: { env?: Record; filePath?: string; overwrite?: boolean } = {} -): Promise { - const env = input.env ?? process.env - const filePath = input.filePath ?? resolveDefaultConfigPath(env) - const overwrite = input.overwrite === true - - if (!overwrite && fs.existsSync(filePath)) { - return { filePath, created: false } - } - - await fsPromises.mkdir(path.dirname(filePath), { recursive: true }) - const content = DEFAULT_CODEX_CONFIG_TEMPLATE - await fsPromises.writeFile(filePath, content, { encoding: "utf8", mode: 0o600 }) - try { - await fsPromises.chmod(filePath, 0o600) - } catch (error) { - if (error instanceof Error) { - // best-effort permission hardening - } - } - return { filePath, created: true } -} - -export function loadConfigFile( - input: { env?: Record; filePath?: string } = {} -): Partial { - const env = input.env ?? process.env - const explicitPath = input.filePath ?? env.OPENCODE_OPENAI_MULTI_CONFIG_PATH?.trim() - - const candidates = explicitPath ? [explicitPath] : [resolveDefaultConfigPath(env)] - - for (const filePath of candidates) { - if (!filePath) continue - if (!fs.existsSync(filePath)) continue - try { - const raw = fs.readFileSync(filePath, "utf8") - const parsed = parseConfigJsonWithComments(raw) - const validation = validateConfigFileObject(parsed) - if (!validation.valid) { - const message = `[opencode-codex-auth] Invalid codex-config at ${filePath}. ${validation.issues.join("; ")}` - console.warn(message) - continue - } - return parseConfigFileObject(parsed) - } catch (error) { - const detail = error instanceof Error ? error.message : String(error) - const message = `[opencode-codex-auth] Failed to read codex-config at ${filePath}. ${detail}` - console.warn(message) - continue - } - } - - return {} -} diff --git a/lib/config/parse.ts b/lib/config/parse.ts deleted file mode 100644 index bfd94f2..0000000 --- a/lib/config/parse.ts +++ /dev/null @@ -1,378 +0,0 @@ -import type { RotationStrategy } from "../types.js" -import { isRecord } from "../util.js" -import { cloneBehaviorSettings } from "./behavior-settings.js" -import type { - BehaviorSettings, - CodexSpoofMode, - ModelBehaviorOverride, - ModelConfigOverride, - PersonalityOption, - PluginConfig, - PluginRuntimeMode, - PromptCacheKeyStrategy, - ServiceTierOption, - VerbosityOption -} from "./types.js" -export function parseEnvBoolean(value: string | undefined): boolean | undefined { - if (value === undefined) return undefined - const normalized = value.trim().toLowerCase() - if (normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on") return true - if (normalized === "0" || normalized === "false" || normalized === "no" || normalized === "off") return false - return undefined -} -export function parseEnvNumber(value: string | undefined): number | undefined { - if (value === undefined) return undefined - if (value.trim().length === 0) return undefined - const n = Number(value) - return Number.isFinite(n) ? n : undefined -} -function stripJsonComments(raw: string): string { - let out = "" - let inString = false - let escaped = false - let inLineComment = false - let inBlockComment = false - - for (let index = 0; index < raw.length; index += 1) { - const ch = raw[index] - const next = raw[index + 1] - - if (inLineComment) { - if (ch === "\n" || ch === "\r") { - inLineComment = false - out += ch - } - continue - } - - if (inBlockComment) { - if (ch === "*" && next === "/") { - inBlockComment = false - index += 1 - continue - } - if (ch === "\n" || ch === "\r") { - out += ch - } - continue - } - - if (inString) { - out += ch - if (escaped) { - escaped = false - } else if (ch === "\\") { - escaped = true - } else if (ch === '"') { - inString = false - } - continue - } - - if (ch === '"') { - inString = true - out += ch - continue - } - - if (ch === "/" && next === "/") { - inLineComment = true - index += 1 - continue - } - - if (ch === "/" && next === "*") { - inBlockComment = true - index += 1 - continue - } - - out += ch - } - - return out -} -export function parseConfigJsonWithComments(raw: string): unknown { - return JSON.parse(stripJsonComments(raw)) as unknown -} -export function normalizePersonalityOption(value: unknown): PersonalityOption | undefined { - if (typeof value !== "string") return undefined - const normalized = value.trim().toLowerCase() - if (!normalized) return undefined - if (normalized.includes("/") || normalized.includes("\\") || normalized.includes("..")) { - return undefined - } - return normalized -} -export function parseSpoofMode(value: unknown): CodexSpoofMode | undefined { - if (typeof value !== "string") return undefined - const normalized = value.trim().toLowerCase() - if (normalized === "native") return "native" - if (normalized === "codex") return "codex" - return undefined -} -export function parseRuntimeMode(value: unknown): PluginRuntimeMode | undefined { - if (typeof value !== "string") return undefined - const normalized = value.trim().toLowerCase() - if (normalized === "native") return "native" - if (normalized === "codex") return "codex" - return undefined -} -export function parseRotationStrategy(value: unknown): RotationStrategy | undefined { - if (typeof value !== "string") return undefined - const normalized = value.trim().toLowerCase() - if (normalized === "sticky" || normalized === "hybrid" || normalized === "round_robin") { - return normalized - } - return undefined -} -export function parsePromptCacheKeyStrategy(value: unknown): PromptCacheKeyStrategy | undefined { - if (typeof value !== "string") return undefined - const normalized = value.trim().toLowerCase() - if (normalized === "default" || normalized === "project") return normalized - return undefined -} -export function normalizeVerbosityOption(value: unknown): VerbosityOption | undefined { - if (typeof value !== "string") return undefined - const normalized = value.trim().toLowerCase() - if (normalized === "default" || normalized === "low" || normalized === "medium" || normalized === "high") { - return normalized - } - return undefined -} -export function normalizeServiceTierOption(value: unknown): ServiceTierOption | undefined { - if (typeof value !== "string") return undefined - const normalized = value.trim().toLowerCase() - if (normalized === "default" || normalized === "priority" || normalized === "flex") { - return normalized - } - return undefined -} -type ModelBehaviorSettings = { - personality?: PersonalityOption - thinkingSummaries?: boolean - verbosityEnabled?: boolean - verbosity?: VerbosityOption - serviceTier?: ServiceTierOption -} - -function normalizeModelBehaviorSettings(raw: unknown): ModelBehaviorSettings | undefined { - if (!isRecord(raw)) return undefined - const out: ModelBehaviorSettings = {} - - const personality = normalizePersonalityOption(raw.personality) - if (personality) out.personality = personality - - if (typeof raw.thinkingSummaries === "boolean") { - out.thinkingSummaries = raw.thinkingSummaries - } - - if (typeof raw.verbosityEnabled === "boolean") { - out.verbosityEnabled = raw.verbosityEnabled - } - - const verbosity = normalizeVerbosityOption(raw.verbosity) - if (verbosity) { - out.verbosity = verbosity - } - - const serviceTier = normalizeServiceTierOption(raw.serviceTier) - if (serviceTier) { - out.serviceTier = serviceTier - } - - if ( - !out.personality && - out.thinkingSummaries === undefined && - out.verbosityEnabled === undefined && - out.verbosity === undefined && - out.serviceTier === undefined - ) { - return undefined - } - - return out -} - -function normalizeModelConfigOverride(raw: unknown): ModelConfigOverride | undefined { - if (!isRecord(raw)) return undefined - - const modelBehavior = normalizeModelBehaviorSettings(raw) - const rawVariants = isRecord(raw.variants) ? raw.variants : undefined - - let variants: ModelConfigOverride["variants"] | undefined - if (rawVariants) { - const variantMap: NonNullable = {} - for (const [variantName, value] of Object.entries(rawVariants)) { - const normalized = normalizeModelBehaviorSettings(value) - if (!normalized) continue - variantMap[variantName] = { - ...(normalized.personality ? { personality: normalized.personality } : {}), - ...(normalized.thinkingSummaries !== undefined ? { thinkingSummaries: normalized.thinkingSummaries } : {}), - ...(normalized.verbosityEnabled !== undefined ? { verbosityEnabled: normalized.verbosityEnabled } : {}), - ...(normalized.verbosity ? { verbosity: normalized.verbosity } : {}), - ...(normalized.serviceTier ? { serviceTier: normalized.serviceTier } : {}) - } - } - if (Object.keys(variantMap).length > 0) { - variants = variantMap - } - } - - if (!modelBehavior && !variants) { - return undefined - } - - return { - ...(modelBehavior?.personality ? { personality: modelBehavior.personality } : {}), - ...(modelBehavior?.thinkingSummaries !== undefined ? { thinkingSummaries: modelBehavior.thinkingSummaries } : {}), - ...(modelBehavior?.verbosityEnabled !== undefined ? { verbosityEnabled: modelBehavior.verbosityEnabled } : {}), - ...(modelBehavior?.verbosity ? { verbosity: modelBehavior.verbosity } : {}), - ...(modelBehavior?.serviceTier ? { serviceTier: modelBehavior.serviceTier } : {}), - ...(variants ? { variants } : {}) - } -} - -function normalizeNewBehaviorSections(raw: Record): BehaviorSettings | undefined { - const global = normalizeModelBehaviorSettings(raw.global) - const perModelRaw = isRecord(raw.perModel) ? raw.perModel : undefined - - let perModel: BehaviorSettings["perModel"] | undefined - if (perModelRaw) { - const modelMap: NonNullable = {} - for (const [modelName, value] of Object.entries(perModelRaw)) { - const normalized = normalizeModelConfigOverride(value) - if (!normalized) continue - modelMap[modelName] = normalized - } - if (Object.keys(modelMap).length > 0) { - perModel = modelMap - } - } - - if (!global && !perModel) { - return undefined - } - - return { - ...(global ? { global } : {}), - ...(perModel ? { perModel } : {}) - } -} - -export function parseConfigFileObject(raw: unknown): Partial { - if (!isRecord(raw)) return {} - - const behaviorSettings = normalizeNewBehaviorSections(raw) - const personalityFromBehavior = behaviorSettings?.global?.personality - - const debug = typeof raw.debug === "boolean" ? raw.debug : undefined - const proactiveRefresh = - isRecord(raw.refreshAhead) && typeof raw.refreshAhead.enabled === "boolean" ? raw.refreshAhead.enabled : undefined - const proactiveRefreshBufferMs = - isRecord(raw.refreshAhead) && typeof raw.refreshAhead.bufferMs === "number" ? raw.refreshAhead.bufferMs : undefined - const quietMode = typeof raw.quiet === "boolean" ? raw.quiet : undefined - const mode = parseRuntimeMode(isRecord(raw.runtime) ? raw.runtime.mode : undefined) - const rotationStrategy = parseRotationStrategy(isRecord(raw.runtime) ? raw.runtime.rotationStrategy : undefined) - const promptCacheKeyStrategy = parsePromptCacheKeyStrategy( - isRecord(raw.runtime) ? raw.runtime.promptCacheKeyStrategy : undefined - ) - const spoofMode = mode === "native" ? "native" : mode === "codex" ? "codex" : undefined - const compatInputSanitizer = - isRecord(raw.runtime) && typeof raw.runtime.sanitizeInputs === "boolean" ? raw.runtime.sanitizeInputs : undefined - const remapDeveloperMessagesToUser = - isRecord(raw.runtime) && typeof raw.runtime.developerMessagesToUser === "boolean" - ? raw.runtime.developerMessagesToUser - : undefined - const codexCompactionOverride = - isRecord(raw.runtime) && typeof raw.runtime.codexCompactionOverride === "boolean" - ? raw.runtime.codexCompactionOverride - : undefined - const headerSnapshots = - isRecord(raw.runtime) && typeof raw.runtime.headerSnapshots === "boolean" ? raw.runtime.headerSnapshots : undefined - const headerSnapshotBodies = - isRecord(raw.runtime) && typeof raw.runtime.headerSnapshotBodies === "boolean" - ? raw.runtime.headerSnapshotBodies - : undefined - const headerTransformDebug = - isRecord(raw.runtime) && typeof raw.runtime.headerTransformDebug === "boolean" - ? raw.runtime.headerTransformDebug - : undefined - const pidOffsetEnabled = - isRecord(raw.runtime) && typeof raw.runtime.pidOffset === "boolean" ? raw.runtime.pidOffset : undefined - const collaborationProfileEnabled = - isRecord(raw.runtime) && typeof raw.runtime.collaborationProfile === "boolean" - ? raw.runtime.collaborationProfile - : undefined - const orchestratorSubagentsEnabled = - isRecord(raw.runtime) && typeof raw.runtime.orchestratorSubagents === "boolean" - ? raw.runtime.orchestratorSubagents - : undefined - - return { - debug, - proactiveRefresh, - proactiveRefreshBufferMs, - quiet: quietMode, - quietMode, - pidOffsetEnabled, - personality: personalityFromBehavior, - mode, - rotationStrategy, - promptCacheKeyStrategy, - spoofMode, - compatInputSanitizer, - remapDeveloperMessagesToUser, - codexCompactionOverride, - headerSnapshots, - headerSnapshotBodies, - headerTransformDebug, - collaborationProfile: collaborationProfileEnabled, - collaborationProfileEnabled, - orchestratorSubagents: orchestratorSubagentsEnabled, - orchestratorSubagentsEnabled, - behaviorSettings - } -} - -export function buildResolvedBehaviorSettings(input: { - fileBehavior: BehaviorSettings | undefined - envPersonality: PersonalityOption | undefined - envThinkingSummaries: boolean | undefined - envVerbosityEnabled: boolean | undefined - envVerbosity: VerbosityOption | undefined - envServiceTier: ServiceTierOption | undefined -}): BehaviorSettings | undefined { - const behaviorSettings = cloneBehaviorSettings(input.fileBehavior) ?? {} - const globalBehavior: ModelBehaviorOverride = { - ...(behaviorSettings.global ?? {}) - } - - if (input.envPersonality) { - globalBehavior.personality = input.envPersonality - } - if (input.envThinkingSummaries !== undefined) { - globalBehavior.thinkingSummaries = input.envThinkingSummaries - } - if (input.envVerbosityEnabled !== undefined) { - globalBehavior.verbosityEnabled = input.envVerbosityEnabled - } - if (input.envVerbosity) { - globalBehavior.verbosity = input.envVerbosity - } - if (input.envServiceTier) { - globalBehavior.serviceTier = input.envServiceTier - } - - if ( - globalBehavior.personality !== undefined || - globalBehavior.thinkingSummaries !== undefined || - globalBehavior.verbosityEnabled !== undefined || - globalBehavior.verbosity !== undefined || - globalBehavior.serviceTier !== undefined - ) { - behaviorSettings.global = globalBehavior - } - - return behaviorSettings.global !== undefined || behaviorSettings.perModel !== undefined ? behaviorSettings : undefined -} diff --git a/lib/config/resolve.ts b/lib/config/resolve.ts index ac5f0ca..b17db69 100644 --- a/lib/config/resolve.ts +++ b/lib/config/resolve.ts @@ -1,6 +1,5 @@ import type { RotationStrategy } from "../types.js" import { - buildResolvedBehaviorSettings, normalizePersonalityOption, normalizeServiceTierOption, normalizeVerbosityOption, @@ -10,16 +9,95 @@ import { parseRotationStrategy, parseRuntimeMode, parseSpoofMode -} from "./parse.js" +} from "./file.js" import type { BehaviorSettings, CodexSpoofMode, + ModelBehaviorOverride, PersonalityOption, PluginConfig, PluginRuntimeMode, PromptCacheKeyStrategy } from "./types.js" +function cloneBehaviorOverride>(input: T | undefined): T | undefined { + if (!input) return undefined + return { ...input } +} + +export function cloneBehaviorSettings(input: BehaviorSettings | undefined): BehaviorSettings | undefined { + if (!input) return undefined + return { + ...(input.global + ? { + global: cloneBehaviorOverride(input.global) + } + : {}), + perModel: input.perModel + ? Object.fromEntries( + Object.entries(input.perModel).map(([key, value]) => [ + key, + { + ...cloneBehaviorOverride(value), + ...(value.variants + ? { + variants: Object.fromEntries( + Object.entries(value.variants).map(([variantKey, variantValue]) => [ + variantKey, + cloneBehaviorOverride(variantValue) ?? {} + ]) + ) + } + : {}) + } + ]) + ) + : undefined + } +} + +export function buildResolvedBehaviorSettings(input: { + fileBehavior: BehaviorSettings | undefined + envPersonality: PersonalityOption | undefined + envThinkingSummaries: boolean | undefined + envVerbosityEnabled: boolean | undefined + envVerbosity: ModelBehaviorOverride["verbosity"] + envServiceTier: ModelBehaviorOverride["serviceTier"] +}): BehaviorSettings | undefined { + const behaviorSettings = cloneBehaviorSettings(input.fileBehavior) ?? {} + const globalBehavior: ModelBehaviorOverride = { + ...(behaviorSettings.global ?? {}) + } + + if (input.envPersonality) { + globalBehavior.personality = input.envPersonality + } + if (input.envThinkingSummaries !== undefined) { + globalBehavior.thinkingSummaries = input.envThinkingSummaries + } + if (input.envVerbosityEnabled !== undefined) { + globalBehavior.verbosityEnabled = input.envVerbosityEnabled + } + if (input.envVerbosity) { + globalBehavior.verbosity = input.envVerbosity + } + if (input.envServiceTier) { + globalBehavior.serviceTier = input.envServiceTier + } + + if ( + globalBehavior.personality !== undefined || + globalBehavior.thinkingSummaries !== undefined || + globalBehavior.verbosityEnabled !== undefined || + globalBehavior.verbosity !== undefined || + globalBehavior.serviceTier !== undefined + ) { + behaviorSettings.global = globalBehavior + } + + return behaviorSettings.global !== undefined || behaviorSettings.perModel !== undefined ? behaviorSettings : undefined +} + export function resolveConfig(input: { env: Record file?: Partial diff --git a/lib/config/validation.ts b/lib/config/validation.ts deleted file mode 100644 index 7636623..0000000 --- a/lib/config/validation.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { isRecord } from "../util.js" - -export type ConfigValidationResult = { - valid: boolean - issues: string[] -} - -function describeValueType(value: unknown): string { - if (Array.isArray(value)) return "array" - if (value === null) return "null" - return typeof value -} - -function pushValidationIssue( - issues: string[], - input: { - path: string - expected: string - actual: unknown - } -): void { - issues.push(`${input.path}: expected ${input.expected}, got ${describeValueType(input.actual)}`) -} - -function validateModelBehaviorShape(value: unknown, pathPrefix: string, issues: string[]): void { - if (!isRecord(value)) { - pushValidationIssue(issues, { path: pathPrefix, expected: "object", actual: value }) - return - } - - if ("personality" in value && typeof value.personality !== "string") { - pushValidationIssue(issues, { path: `${pathPrefix}.personality`, expected: "string", actual: value.personality }) - } - if ("thinkingSummaries" in value && typeof value.thinkingSummaries !== "boolean") { - pushValidationIssue(issues, { - path: `${pathPrefix}.thinkingSummaries`, - expected: "boolean", - actual: value.thinkingSummaries - }) - } - if ("verbosityEnabled" in value && typeof value.verbosityEnabled !== "boolean") { - pushValidationIssue(issues, { - path: `${pathPrefix}.verbosityEnabled`, - expected: "boolean", - actual: value.verbosityEnabled - }) - } - if ("verbosity" in value) { - const verbosity = value.verbosity - const normalized = typeof verbosity === "string" ? verbosity.trim().toLowerCase() : "" - if (!(normalized === "default" || normalized === "low" || normalized === "medium" || normalized === "high")) { - pushValidationIssue(issues, { - path: `${pathPrefix}.verbosity`, - expected: '"default" | "low" | "medium" | "high"', - actual: verbosity - }) - } - } - if ("serviceTier" in value) { - const serviceTier = value.serviceTier - const normalized = typeof serviceTier === "string" ? serviceTier.trim().toLowerCase() : "" - if (!(normalized === "default" || normalized === "priority" || normalized === "flex")) { - pushValidationIssue(issues, { - path: `${pathPrefix}.serviceTier`, - expected: '"default" | "priority" | "flex"', - actual: serviceTier - }) - } - } -} - -export function validateConfigFileObject(raw: unknown): ConfigValidationResult { - const issues: string[] = [] - if (!isRecord(raw)) { - pushValidationIssue(issues, { path: "$", expected: "object", actual: raw }) - return { valid: false, issues } - } - - if ("$schema" in raw && typeof raw.$schema !== "string") { - pushValidationIssue(issues, { path: "$schema", expected: "string", actual: raw.$schema }) - } - if ("debug" in raw && typeof raw.debug !== "boolean") { - pushValidationIssue(issues, { path: "debug", expected: "boolean", actual: raw.debug }) - } - if ("quiet" in raw && typeof raw.quiet !== "boolean") { - pushValidationIssue(issues, { path: "quiet", expected: "boolean", actual: raw.quiet }) - } - - if ("refreshAhead" in raw) { - if (!isRecord(raw.refreshAhead)) { - pushValidationIssue(issues, { path: "refreshAhead", expected: "object", actual: raw.refreshAhead }) - } else { - if ("enabled" in raw.refreshAhead && typeof raw.refreshAhead.enabled !== "boolean") { - pushValidationIssue(issues, { - path: "refreshAhead.enabled", - expected: "boolean", - actual: raw.refreshAhead.enabled - }) - } - if ( - "bufferMs" in raw.refreshAhead && - (typeof raw.refreshAhead.bufferMs !== "number" || !Number.isFinite(raw.refreshAhead.bufferMs)) - ) { - pushValidationIssue(issues, { - path: "refreshAhead.bufferMs", - expected: "number", - actual: raw.refreshAhead.bufferMs - }) - } - } - } - - if ("runtime" in raw) { - if (!isRecord(raw.runtime)) { - pushValidationIssue(issues, { path: "runtime", expected: "object", actual: raw.runtime }) - } else { - const runtime = raw.runtime - const enumChecks: Array<{ field: string; allowed: string[] }> = [ - { field: "mode", allowed: ["native", "codex"] }, - { field: "rotationStrategy", allowed: ["sticky", "hybrid", "round_robin"] }, - { field: "promptCacheKeyStrategy", allowed: ["default", "project"] } - ] - for (const check of enumChecks) { - const value = runtime[check.field] - if (value === undefined) continue - const normalized = typeof value === "string" ? value.trim().toLowerCase() : "" - if (!check.allowed.includes(normalized)) { - pushValidationIssue(issues, { - path: `runtime.${check.field}`, - expected: check.allowed.map((item) => `"${item}"`).join(" | "), - actual: value - }) - } - } - - const boolFields = [ - "sanitizeInputs", - "developerMessagesToUser", - "codexCompactionOverride", - "headerSnapshots", - "headerSnapshotBodies", - "headerTransformDebug", - "pidOffset", - "collaborationProfile", - "orchestratorSubagents" - ] - for (const field of boolFields) { - if (field in runtime && typeof runtime[field] !== "boolean") { - pushValidationIssue(issues, { - path: `runtime.${field}`, - expected: "boolean", - actual: runtime[field] - }) - } - } - } - } - - if ("global" in raw) { - validateModelBehaviorShape(raw.global, "global", issues) - } - - if ("perModel" in raw) { - if (!isRecord(raw.perModel)) { - pushValidationIssue(issues, { path: "perModel", expected: "object", actual: raw.perModel }) - } else { - for (const [modelName, modelValue] of Object.entries(raw.perModel)) { - validateModelBehaviorShape(modelValue, `perModel.${modelName}`, issues) - if (!isRecord(modelValue)) continue - if (!("variants" in modelValue)) continue - - const variants = modelValue.variants - if (!isRecord(variants)) { - pushValidationIssue(issues, { - path: `perModel.${modelName}.variants`, - expected: "object", - actual: variants - }) - continue - } - for (const [variantName, variantValue] of Object.entries(variants)) { - validateModelBehaviorShape(variantValue, `perModel.${modelName}.variants.${variantName}`, issues) - } - } - } - } - - return { valid: issues.length === 0, issues } -} diff --git a/lib/storage.ts b/lib/storage.ts index 7dd1c38..4e05e3d 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -7,17 +7,22 @@ import { ensureConfigDirGitignore } from "./config-dir-gitignore.js" import { withLockedFile } from "./cache-lock.js" import { isFsErrorCode, writeJsonFileAtomic } from "./cache-io.js" import type { AuthFile, OpenAIAuthMode } from "./types.js" -import { ensureOpenAIOAuthDomain, normalizeOpenAIOAuthState, OPENAI_AUTH_MODES } from "./storage/domain-state.js" import { + ensureOpenAIOAuthDomain, ensureMultiOauthState, + getOpenAIOAuthDomain, hasUsableOpenAIOAuth, listLegacyAuthCandidates, + listOpenAIOAuthDomains, migrateAuthFile, migrateLegacyCodexAccounts, + normalizeOpenAIOAuthState, + OPENAI_AUTH_MODES, + requireOpenAIMultiOauthAuth, sanitizeAuthFile, shouldEnforceOpenAIOnlyStorage, upsertDomainAccount -} from "./storage/migration.js" +} from "./storage/auth-state.js" type AuthLoadOptions = { quarantineDir?: string @@ -259,4 +264,4 @@ export { getOpenAIOAuthDomain, listOpenAIOAuthDomains, requireOpenAIMultiOauthAuth -} from "./storage/domain-state.js" +} from "./storage/auth-state.js" diff --git a/lib/storage/domain-state.ts b/lib/storage/auth-state.ts similarity index 56% rename from lib/storage/domain-state.ts rename to lib/storage/auth-state.ts index 8ae8b3d..bdf12a2 100644 --- a/lib/storage/domain-state.ts +++ b/lib/storage/auth-state.ts @@ -1,5 +1,8 @@ +import path from "node:path" + import { normalizeAccountAuthTypes } from "../account-auth-types.js" import { + buildIdentityKey, buildLegacyIdentityFingerprint, ensureIdentityKey, normalizeEmail, @@ -7,6 +10,12 @@ import { synchronizeIdentityKey } from "../identity.js" import { extractAccountIdFromClaims, extractEmailFromClaims, extractPlanFromClaims, parseJwtClaims } from "../claims.js" +import { + CODEX_ACCOUNTS_FILE, + legacyOpenAICodexAccountsPathFor, + opencodeProviderAuthLegacyFallbackPath, + opencodeProviderAuthPath +} from "../paths.js" import { isRecord as isObject } from "../util.js" import type { AccountRecord, AuthFile, OpenAIAuthMode, OpenAIOAuthDomain, OpenAIMultiOauthAuth } from "../types.js" @@ -20,6 +29,22 @@ export type LegacyOpenAIOauth = { plan?: string } +type LegacyCodexAccountsRecord = { + refreshToken?: unknown + accessToken?: unknown + access?: unknown + expires?: unknown + expiresAt?: unknown + accountId?: unknown + email?: unknown + plan?: unknown + enabled?: unknown + authTypes?: unknown + lastUsed?: unknown + coolingDownUntil?: unknown + cooldownUntil?: unknown +} + export const OPENAI_AUTH_MODES: OpenAIAuthMode[] = ["native", "codex"] function ensureAccountAuthTypes(account: AccountRecord): void { @@ -251,6 +276,47 @@ export function getOpenAIOAuthDomain(auth: AuthFile, authMode: OpenAIAuthMode): return normalized[authMode] } +export function requireOpenAIMultiOauthAuth(auth: AuthFile): OpenAIMultiOauthAuth { + const openai = auth.openai + if (!openai || openai.type !== "oauth") { + throw new Error("OpenAI OAuth not configured") + } + + if (isMultiOauthAuth(openai)) { + const normalized = normalizeOpenAIOAuthState(openai) + auth.openai = normalized + return normalized + } + + if (isLegacyOauthAuth(openai)) { + const account: AccountRecord = ensureIdentityKey({ + access: openai.access, + refresh: openai.refresh, + expires: openai.expires, + accountId: openai.accountId, + email: openai.email, + plan: openai.plan, + authTypes: ["native"], + enabled: true + }) + + const migrated: OpenAIMultiOauthAuth = { + type: "oauth", + accounts: [], + native: { + accounts: [account], + activeIdentityKey: account.identityKey + } + } + + const normalized = normalizeOpenAIOAuthState(migrated) + auth.openai = normalized + return normalized + } + + throw new Error("Invalid OpenAI OAuth config") +} + export function ensureOpenAIOAuthDomain(auth: AuthFile, authMode: OpenAIAuthMode): OpenAIOAuthDomain { const openai = requireOpenAIMultiOauthAuth(auth) const normalized = normalizeOpenAIOAuthState(openai) @@ -279,43 +345,243 @@ export function listOpenAIOAuthDomains(auth: AuthFile): Array<{ mode: OpenAIAuth return out } -export function requireOpenAIMultiOauthAuth(auth: AuthFile): OpenAIMultiOauthAuth { - const openai = auth.openai - if (!openai || openai.type !== "oauth") { - throw new Error("OpenAI OAuth not configured") +export function listLegacyProviderAuthCandidates(env: Record = process.env): string[] { + const primary = opencodeProviderAuthPath(env) + const legacyFallback = opencodeProviderAuthLegacyFallbackPath(env) + if (primary === legacyFallback) return [primary] + return [primary, legacyFallback] +} + +export function listLegacyAuthCandidates(filePath: string): string[] { + return [legacyOpenAICodexAccountsPathFor(filePath), ...listLegacyProviderAuthCandidates(process.env)] +} + +export function sanitizeAuthFile(input: AuthFile, options?: { openAIOnly?: boolean }): AuthFile { + const openAIOnly = options?.openAIOnly !== false + if (openAIOnly) { + if (input.openai) { + return { openai: input.openai } + } + return {} + } + + if (input.openai) { + return { ...(input as Record), openai: input.openai } as AuthFile } + return { ...(input as Record) } as AuthFile +} +export function shouldEnforceOpenAIOnlyStorage(filePath: string): boolean { + return path.basename(filePath) === CODEX_ACCOUNTS_FILE +} + +export function hasUsableOpenAIOAuth(auth: AuthFile): boolean { + const openai = auth.openai + if (!openai || openai.type !== "oauth") return false if (isMultiOauthAuth(openai)) { const normalized = normalizeOpenAIOAuthState(openai) - auth.openai = normalized - return normalized + return OPENAI_AUTH_MODES.some((authMode) => { + const domain = normalized[authMode] + if (!domain) return false + return domain.accounts.some((account) => typeof account.refresh === "string" && account.refresh.trim().length > 0) + }) } + return isLegacyOauthAuth(openai) && openai.refresh.trim().length > 0 +} - if (isLegacyOauthAuth(openai)) { +export function migrateAuthFile(input: AuthFile): AuthFile { + const auth: AuthFile = input ?? {} + const openai = auth.openai + if (!openai || openai.type !== "oauth") return auth + if (isMultiOauthAuth(openai)) { + auth.openai = normalizeOpenAIOAuthState(openai) + return auth + } + if (!isLegacyOauthAuth(openai)) return auth + const claims = parseJwtClaims(openai.access) + + const account: AccountRecord = ensureIdentityKey({ + access: openai.access, + refresh: openai.refresh, + expires: openai.expires, + accountId: openai.accountId || extractAccountIdFromClaims(claims), + email: openai.email || extractEmailFromClaims(claims), + plan: openai.plan || extractPlanFromClaims(claims), + authTypes: ["native"], + enabled: true + }) + + const migrated: OpenAIMultiOauthAuth = { + type: "oauth", + accounts: [], + native: { + accounts: [account], + activeIdentityKey: account.identityKey + } + } + + auth.openai = normalizeOpenAIOAuthState(migrated) + return auth +} + +export function migrateLegacyCodexAccounts(input: Record): AuthFile | undefined { + if (typeof input.openai === "object" && input.openai !== null && !Array.isArray(input.openai)) return undefined + const rawAccounts = Array.isArray(input.accounts) ? input.accounts : undefined + if (!rawAccounts || rawAccounts.length === 0) return undefined + + const mappedAccounts: AccountRecord[] = [] + for (const raw of rawAccounts) { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue + const source = raw as LegacyCodexAccountsRecord + const refreshToken = typeof source.refreshToken === "string" ? source.refreshToken.trim() : "" + if (!refreshToken) continue + + const accessToken = + typeof source.accessToken === "string" + ? source.accessToken + : typeof source.access === "string" + ? source.access + : undefined + const claims = accessToken ? parseJwtClaims(accessToken) : undefined const account: AccountRecord = ensureIdentityKey({ - access: openai.access, - refresh: openai.refresh, - expires: openai.expires, - accountId: openai.accountId, - email: openai.email, - plan: openai.plan, - authTypes: ["native"], - enabled: true + refresh: refreshToken, + access: accessToken, + expires: + typeof source.expiresAt === "number" + ? source.expiresAt + : typeof source.expires === "number" + ? source.expires + : 0, + accountId: typeof source.accountId === "string" ? source.accountId : extractAccountIdFromClaims(claims), + email: typeof source.email === "string" ? source.email : extractEmailFromClaims(claims), + plan: typeof source.plan === "string" ? source.plan : extractPlanFromClaims(claims), + authTypes: normalizeAccountAuthTypes(source.authTypes), + enabled: typeof source.enabled === "boolean" ? source.enabled : true }) + account.authTypes = normalizeAccountAuthTypes(account.authTypes) - const migrated: OpenAIMultiOauthAuth = { + if (typeof source.lastUsed === "number") { + account.lastUsed = source.lastUsed + } + + const cooldownUntil = + typeof source.cooldownUntil === "number" + ? source.cooldownUntil + : typeof source.coolingDownUntil === "number" + ? source.coolingDownUntil + : undefined + if (typeof cooldownUntil === "number") { + account.cooldownUntil = cooldownUntil + } + + mappedAccounts.push(account) + } + + if (mappedAccounts.length === 0) return undefined + + const activeIndex = typeof input.activeIndex === "number" ? Math.floor(input.activeIndex) : 0 + const safeActiveIndex = activeIndex >= 0 && activeIndex < mappedAccounts.length ? activeIndex : 0 + const activeIdentityKey = mappedAccounts[safeActiveIndex]?.identityKey + + const auth: AuthFile = { + openai: { + type: "oauth", + accounts: mappedAccounts, + ...(activeIdentityKey ? { activeIdentityKey } : {}) + } + } + return migrateAuthFile(auth) +} + +export function ensureMultiOauthState(auth: AuthFile): OpenAIMultiOauthAuth { + const openai = auth.openai + if (!openai || openai.type !== "oauth") { + const created: OpenAIMultiOauthAuth = { type: "oauth", accounts: [], - native: { - accounts: [account], - activeIdentityKey: account.identityKey - } + native: { accounts: [] } } + auth.openai = normalizeOpenAIOAuthState(created) + return auth.openai + } + if (!isMultiOauthAuth(openai)) { + const migrated = migrateAuthFile({ openai }).openai + if (migrated && isMultiOauthAuth(migrated)) { + auth.openai = normalizeOpenAIOAuthState(migrated) + return auth.openai + } + const created: OpenAIMultiOauthAuth = { + type: "oauth", + accounts: [], + native: { accounts: [] } + } + auth.openai = normalizeOpenAIOAuthState(created) + return auth.openai + } + auth.openai = normalizeOpenAIOAuthState(openai) + return auth.openai +} - const normalized = normalizeOpenAIOAuthState(migrated) - auth.openai = normalized - return normalized +export function upsertDomainAccount( + domain: OpenAIOAuthDomain, + input: AccountRecord, + authMode: OpenAIAuthMode +): boolean { + const incoming: AccountRecord = { ...input, authTypes: [authMode] } + normalizeAccountRecord(incoming, authMode) + + const incomingIdentity = incoming.identityKey + const incomingStrictIdentity = buildIdentityKey(incoming) + const incomingRefresh = typeof incoming.refresh === "string" ? incoming.refresh : "" + let strictMatchIndex = -1 + let refreshFallbackMatchIndex = -1 + let refreshFallbackAmbiguous = false + + domain.accounts.forEach((existing, index) => { + if (strictMatchIndex >= 0) return + normalizeAccountRecord(existing, authMode) + const existingStrictIdentity = buildIdentityKey(existing) + if (incomingIdentity && existing.identityKey === incomingIdentity) { + strictMatchIndex = index + return + } + if (incomingStrictIdentity && existingStrictIdentity && incomingStrictIdentity === existingStrictIdentity) { + strictMatchIndex = index + return + } + if ( + incomingRefresh && + existing.refresh === incomingRefresh && + (!incomingStrictIdentity || !existingStrictIdentity) + ) { + if (refreshFallbackMatchIndex >= 0 && refreshFallbackMatchIndex !== index) { + refreshFallbackAmbiguous = true + } else { + refreshFallbackMatchIndex = index + } + } + }) + + const matchIndex = + strictMatchIndex >= 0 + ? strictMatchIndex + : !refreshFallbackAmbiguous && refreshFallbackMatchIndex >= 0 + ? refreshFallbackMatchIndex + : -1 + + if (matchIndex < 0) { + domain.accounts.push(incoming) + return true } - throw new Error("Invalid OpenAI OAuth config") + const existing = domain.accounts[matchIndex] + if (!existing) return false + const existingExpires = typeof existing.expires === "number" ? existing.expires : -Infinity + const incomingExpires = typeof incoming.expires === "number" ? incoming.expires : -Infinity + const preferIncoming = incomingExpires >= existingExpires + domain.accounts[matchIndex] = preferIncoming + ? { ...existing, ...incoming, authTypes: [authMode] } + : { ...incoming, ...existing, authTypes: [authMode] } + normalizeAccountRecord(domain.accounts[matchIndex], authMode) + return false } diff --git a/lib/storage/migration.ts b/lib/storage/migration.ts deleted file mode 100644 index 6602124..0000000 --- a/lib/storage/migration.ts +++ /dev/null @@ -1,275 +0,0 @@ -import path from "node:path" - -import { normalizeAccountAuthTypes } from "../account-auth-types.js" -import { buildIdentityKey, ensureIdentityKey } from "../identity.js" -import { extractAccountIdFromClaims, extractEmailFromClaims, extractPlanFromClaims, parseJwtClaims } from "../claims.js" -import { - CODEX_ACCOUNTS_FILE, - legacyOpenAICodexAccountsPathFor, - opencodeProviderAuthLegacyFallbackPath, - opencodeProviderAuthPath -} from "../paths.js" -import type { AccountRecord, AuthFile, OpenAIAuthMode, OpenAIOAuthDomain, OpenAIMultiOauthAuth } from "../types.js" -import { - isLegacyOauthAuth, - isMultiOauthAuth, - normalizeAccountRecord, - normalizeOpenAIOAuthState, - OPENAI_AUTH_MODES -} from "./domain-state.js" - -type LegacyCodexAccountsRecord = { - refreshToken?: unknown - accessToken?: unknown - access?: unknown - expires?: unknown - expiresAt?: unknown - accountId?: unknown - email?: unknown - plan?: unknown - enabled?: unknown - authTypes?: unknown - lastUsed?: unknown - coolingDownUntil?: unknown - cooldownUntil?: unknown -} - -export function listLegacyProviderAuthCandidates(env: Record = process.env): string[] { - const primary = opencodeProviderAuthPath(env) - const legacyFallback = opencodeProviderAuthLegacyFallbackPath(env) - if (primary === legacyFallback) return [primary] - return [primary, legacyFallback] -} - -export function listLegacyAuthCandidates(filePath: string): string[] { - return [legacyOpenAICodexAccountsPathFor(filePath), ...listLegacyProviderAuthCandidates(process.env)] -} - -export function sanitizeAuthFile(input: AuthFile, options?: { openAIOnly?: boolean }): AuthFile { - const openAIOnly = options?.openAIOnly !== false - if (openAIOnly) { - if (input.openai) { - return { openai: input.openai } - } - return {} - } - - if (input.openai) { - return { ...(input as Record), openai: input.openai } as AuthFile - } - return { ...(input as Record) } as AuthFile -} - -export function shouldEnforceOpenAIOnlyStorage(filePath: string): boolean { - return path.basename(filePath) === CODEX_ACCOUNTS_FILE -} - -export function hasUsableOpenAIOAuth(auth: AuthFile): boolean { - const openai = auth.openai - if (!openai || openai.type !== "oauth") return false - if (isMultiOauthAuth(openai)) { - const normalized = normalizeOpenAIOAuthState(openai) - return OPENAI_AUTH_MODES.some((authMode) => { - const domain = normalized[authMode] - if (!domain) return false - return domain.accounts.some((account) => typeof account.refresh === "string" && account.refresh.trim().length > 0) - }) - } - return isLegacyOauthAuth(openai) && openai.refresh.trim().length > 0 -} - -export function migrateAuthFile(input: AuthFile): AuthFile { - const auth: AuthFile = input ?? {} - const openai = auth.openai - if (!openai || openai.type !== "oauth") return auth - if (isMultiOauthAuth(openai)) { - auth.openai = normalizeOpenAIOAuthState(openai) - return auth - } - if (!isLegacyOauthAuth(openai)) return auth - const claims = parseJwtClaims(openai.access) - - const account: AccountRecord = ensureIdentityKey({ - access: openai.access, - refresh: openai.refresh, - expires: openai.expires, - accountId: openai.accountId || extractAccountIdFromClaims(claims), - email: openai.email || extractEmailFromClaims(claims), - plan: openai.plan || extractPlanFromClaims(claims), - authTypes: ["native"], - enabled: true - }) - - const migrated: OpenAIMultiOauthAuth = { - type: "oauth", - accounts: [], - native: { - accounts: [account], - activeIdentityKey: account.identityKey - } - } - - auth.openai = normalizeOpenAIOAuthState(migrated) - return auth -} - -export function migrateLegacyCodexAccounts(input: Record): AuthFile | undefined { - if (typeof input.openai === "object" && input.openai !== null && !Array.isArray(input.openai)) return undefined - const rawAccounts = Array.isArray(input.accounts) ? input.accounts : undefined - if (!rawAccounts || rawAccounts.length === 0) return undefined - - const mappedAccounts: AccountRecord[] = [] - for (const raw of rawAccounts) { - if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue - const source = raw as LegacyCodexAccountsRecord - const refreshToken = typeof source.refreshToken === "string" ? source.refreshToken.trim() : "" - if (!refreshToken) continue - - const accessToken = - typeof source.accessToken === "string" - ? source.accessToken - : typeof source.access === "string" - ? source.access - : undefined - const claims = accessToken ? parseJwtClaims(accessToken) : undefined - const account: AccountRecord = ensureIdentityKey({ - refresh: refreshToken, - access: accessToken, - expires: - typeof source.expiresAt === "number" - ? source.expiresAt - : typeof source.expires === "number" - ? source.expires - : 0, - accountId: typeof source.accountId === "string" ? source.accountId : extractAccountIdFromClaims(claims), - email: typeof source.email === "string" ? source.email : extractEmailFromClaims(claims), - plan: typeof source.plan === "string" ? source.plan : extractPlanFromClaims(claims), - authTypes: normalizeAccountAuthTypes(source.authTypes), - enabled: typeof source.enabled === "boolean" ? source.enabled : true - }) - account.authTypes = normalizeAccountAuthTypes(account.authTypes) - - if (typeof source.lastUsed === "number") { - account.lastUsed = source.lastUsed - } - - const cooldownUntil = - typeof source.cooldownUntil === "number" - ? source.cooldownUntil - : typeof source.coolingDownUntil === "number" - ? source.coolingDownUntil - : undefined - if (typeof cooldownUntil === "number") { - account.cooldownUntil = cooldownUntil - } - - mappedAccounts.push(account) - } - - if (mappedAccounts.length === 0) return undefined - - const activeIndex = typeof input.activeIndex === "number" ? Math.floor(input.activeIndex) : 0 - const safeActiveIndex = activeIndex >= 0 && activeIndex < mappedAccounts.length ? activeIndex : 0 - const activeIdentityKey = mappedAccounts[safeActiveIndex]?.identityKey - - const auth: AuthFile = { - openai: { - type: "oauth", - accounts: mappedAccounts, - ...(activeIdentityKey ? { activeIdentityKey } : {}) - } - } - return migrateAuthFile(auth) -} - -export function ensureMultiOauthState(auth: AuthFile): OpenAIMultiOauthAuth { - const openai = auth.openai - if (!openai || openai.type !== "oauth") { - const created: OpenAIMultiOauthAuth = { - type: "oauth", - accounts: [], - native: { accounts: [] } - } - auth.openai = normalizeOpenAIOAuthState(created) - return auth.openai - } - if (!isMultiOauthAuth(openai)) { - const migrated = migrateAuthFile({ openai }).openai - if (migrated && isMultiOauthAuth(migrated)) { - auth.openai = normalizeOpenAIOAuthState(migrated) - return auth.openai - } - const created: OpenAIMultiOauthAuth = { - type: "oauth", - accounts: [], - native: { accounts: [] } - } - auth.openai = normalizeOpenAIOAuthState(created) - return auth.openai - } - auth.openai = normalizeOpenAIOAuthState(openai) - return auth.openai -} - -export function upsertDomainAccount( - domain: OpenAIOAuthDomain, - input: AccountRecord, - authMode: OpenAIAuthMode -): boolean { - const incoming: AccountRecord = { ...input, authTypes: [authMode] } - normalizeAccountRecord(incoming, authMode) - - const incomingIdentity = incoming.identityKey - const incomingStrictIdentity = buildIdentityKey(incoming) - const incomingRefresh = typeof incoming.refresh === "string" ? incoming.refresh : "" - let strictMatchIndex = -1 - let refreshFallbackMatchIndex = -1 - let refreshFallbackAmbiguous = false - - domain.accounts.forEach((existing, index) => { - if (strictMatchIndex >= 0) return - normalizeAccountRecord(existing, authMode) - const existingStrictIdentity = buildIdentityKey(existing) - if (incomingIdentity && existing.identityKey === incomingIdentity) { - strictMatchIndex = index - return - } - if (incomingStrictIdentity && existingStrictIdentity && incomingStrictIdentity === existingStrictIdentity) { - strictMatchIndex = index - return - } - if ( - incomingRefresh && - existing.refresh === incomingRefresh && - (!incomingStrictIdentity || !existingStrictIdentity) - ) { - if (refreshFallbackMatchIndex >= 0 && refreshFallbackMatchIndex !== index) { - refreshFallbackAmbiguous = true - } else { - refreshFallbackMatchIndex = index - } - } - }) - const matchIndex = - strictMatchIndex >= 0 - ? strictMatchIndex - : !refreshFallbackAmbiguous && refreshFallbackMatchIndex >= 0 - ? refreshFallbackMatchIndex - : -1 - - if (matchIndex < 0) { - domain.accounts.push(incoming) - return true - } - - const existing = domain.accounts[matchIndex] - if (!existing) return false - const existingExpires = typeof existing.expires === "number" ? existing.expires : -Infinity - const incomingExpires = typeof incoming.expires === "number" ? incoming.expires : -Infinity - const preferIncoming = incomingExpires >= existingExpires - domain.accounts[matchIndex] = preferIncoming - ? { ...existing, ...incoming, authTypes: [authMode] } - : { ...incoming, ...existing, authTypes: [authMode] } - normalizeAccountRecord(domain.accounts[matchIndex], authMode) - return false -} diff --git a/lib/ui/auth-menu-runner.ts b/lib/ui/auth-menu-runner.ts deleted file mode 100644 index 88e8dec..0000000 --- a/lib/ui/auth-menu-runner.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type { AccountAuthType, AccountInfo, DeleteScope } from "./auth-menu.js" -import { showAccountDetails, showAuthMenu, selectAccount } from "./auth-menu.js" - -export type AuthMenuHandlers = { - onCheckQuotas: () => Promise - onConfigureModels: () => Promise - onDeleteAll: (scope: DeleteScope) => Promise - onTransfer: () => Promise - onToggleAccount: (account: AccountInfo) => Promise - onRefreshAccount: (account: AccountInfo) => Promise - onDeleteAccount: (account: AccountInfo, scope: DeleteScope) => Promise -} - -export type AuthMenuResult = "add" | "continue" | "exit" - -function collectAuthTypes(accounts: AccountInfo[]): AccountAuthType[] { - const out: AccountAuthType[] = [] - const seen = new Set() - - for (const account of accounts) { - const authTypes = account.authTypes && account.authTypes.length > 0 ? account.authTypes : ["native"] - for (const authType of authTypes) { - if (authType !== "native" && authType !== "codex") continue - if (seen.has(authType)) continue - seen.add(authType) - out.push(authType) - } - } - - if (out.length === 0) out.push("native") - return out -} - -export async function runAuthMenuOnce(args: { - accounts: AccountInfo[] - handlers: AuthMenuHandlers - allowTransfer?: boolean - input?: NodeJS.ReadStream - output?: NodeJS.WriteStream -}): Promise { - const action = await showAuthMenu(args.accounts, { - input: args.input, - output: args.output, - allowTransfer: args.allowTransfer - }) - - if (action.type === "cancel") return "exit" - if (action.type === "add") return "add" - if (action.type === "check") { - await args.handlers.onCheckQuotas() - return "continue" - } - if (action.type === "configure-models") { - await args.handlers.onConfigureModels() - return "continue" - } - if (action.type === "transfer") { - await args.handlers.onTransfer() - return "continue" - } - if (action.type === "delete-all") { - await args.handlers.onDeleteAll(action.scope) - return "continue" - } - - const account = - action.type === "select-account" - ? action.account - : await selectAccount(args.accounts, { - input: args.input, - output: args.output - }) - if (!account) return "continue" - - const accountAction = await showAccountDetails(account, { - input: args.input, - output: args.output, - availableAuthTypes: collectAuthTypes(args.accounts) - }) - - if (accountAction.type === "toggle") { - await args.handlers.onToggleAccount(account) - return "continue" - } - if (accountAction.type === "refresh") { - if (account.enabled !== false) { - await args.handlers.onRefreshAccount(account) - } - return "continue" - } - if (accountAction.type === "delete") { - await args.handlers.onDeleteAccount(account, accountAction.scope) - return "continue" - } - if (accountAction.type === "delete-all") { - await args.handlers.onDeleteAll(accountAction.scope) - return "continue" - } - - return "continue" -} diff --git a/lib/ui/auth-menu.ts b/lib/ui/auth-menu.ts index 8d1622d..15002c6 100644 --- a/lib/ui/auth-menu.ts +++ b/lib/ui/auth-menu.ts @@ -1,6 +1,4 @@ -import { ANSI, shouldUseColor } from "./tty/ansi.js" -import { confirm } from "./tty/confirm.js" -import { select, type MenuItem } from "./tty/select.js" +import { ANSI, confirm, select, shouldUseColor, type MenuItem } from "./tty.js" import { normalizeAccountAuthTypes as normalizeSharedAccountAuthTypes } from "../account-auth-types.js" export type AccountStatus = "active" | "rate-limited" | "expired" | "unknown" @@ -39,6 +37,18 @@ export type AccountAction = | { type: "toggle" } | { type: "cancel" } +export type AuthMenuHandlers = { + onCheckQuotas: () => Promise + onConfigureModels: () => Promise + onDeleteAll: (scope: DeleteScope) => Promise + onTransfer: () => Promise + onToggleAccount: (account: AccountInfo) => Promise + onRefreshAccount: (account: AccountInfo) => Promise + onDeleteAccount: (account: AccountInfo, scope: DeleteScope) => Promise +} + +export type AuthMenuResult = "add" | "continue" | "exit" + function normalizeAccountAuthTypes(input: readonly AccountAuthType[] | undefined): AccountAuthType[] { return normalizeSharedAccountAuthTypes(input) as AccountAuthType[] } @@ -75,6 +85,24 @@ function authTypesFromAccounts(accounts: AccountInfo[]): AccountAuthType[] { return ordered.length > 0 ? ordered : ["native"] } +function collectAuthTypes(accounts: AccountInfo[]): AccountAuthType[] { + const out: AccountAuthType[] = [] + const seen = new Set() + + for (const account of accounts) { + const authTypes = account.authTypes && account.authTypes.length > 0 ? account.authTypes : ["native"] + for (const authType of authTypes) { + if (authType !== "native" && authType !== "codex") continue + if (seen.has(authType)) continue + seen.add(authType) + out.push(authType) + } + } + + if (out.length === 0) out.push("native") + return out +} + export function formatRelativeTime(timestamp: number | undefined, now = Date.now()): string { if (!timestamp) return "never" const days = Math.floor((now - timestamp) / 86_400_000) @@ -405,3 +433,72 @@ export async function showAccountDetails( return selected } } + +export async function runAuthMenuOnce(args: { + accounts: AccountInfo[] + handlers: AuthMenuHandlers + allowTransfer?: boolean + input?: NodeJS.ReadStream + output?: NodeJS.WriteStream +}): Promise { + const action = await showAuthMenu(args.accounts, { + input: args.input, + output: args.output, + allowTransfer: args.allowTransfer + }) + + if (action.type === "cancel") return "exit" + if (action.type === "add") return "add" + if (action.type === "check") { + await args.handlers.onCheckQuotas() + return "continue" + } + if (action.type === "configure-models") { + await args.handlers.onConfigureModels() + return "continue" + } + if (action.type === "transfer") { + await args.handlers.onTransfer() + return "continue" + } + if (action.type === "delete-all") { + await args.handlers.onDeleteAll(action.scope) + return "continue" + } + + const account = + action.type === "select-account" + ? action.account + : await selectAccount(args.accounts, { + input: args.input, + output: args.output + }) + if (!account) return "continue" + + const accountAction = await showAccountDetails(account, { + input: args.input, + output: args.output, + availableAuthTypes: collectAuthTypes(args.accounts) + }) + + if (accountAction.type === "toggle") { + await args.handlers.onToggleAccount(account) + return "continue" + } + if (accountAction.type === "refresh") { + if (account.enabled !== false) { + await args.handlers.onRefreshAccount(account) + } + return "continue" + } + if (accountAction.type === "delete") { + await args.handlers.onDeleteAccount(account, accountAction.scope) + return "continue" + } + if (accountAction.type === "delete-all") { + await args.handlers.onDeleteAll(accountAction.scope) + return "continue" + } + + return "continue" +} diff --git a/lib/ui/tty/select.ts b/lib/ui/tty.ts similarity index 81% rename from lib/ui/tty/select.ts rename to lib/ui/tty.ts index 2e6f1ad..3df9261 100644 --- a/lib/ui/tty/select.ts +++ b/lib/ui/tty.ts @@ -1,4 +1,18 @@ -import { ANSI, isTTY, parseKey, shouldUseColor } from "./ansi.js" +export const ANSI = { + hide: "\x1b[?25l", + show: "\x1b[?25h", + up: (n = 1) => `\x1b[${n}A`, + clearLine: "\x1b[2K", + cyan: "\x1b[36m", + green: "\x1b[32m", + red: "\x1b[31m", + yellow: "\x1b[33m", + dim: "\x1b[2m", + bold: "\x1b[1m", + reset: "\x1b[0m" +} as const + +export type KeyAction = "up" | "down" | "enter" | "escape" | "escape-start" | null export interface MenuItem { label: string @@ -17,6 +31,12 @@ export interface SelectOptions { useColor?: boolean } +export type ConfirmOptions = { + input?: NodeJS.ReadStream + output?: NodeJS.WriteStream + useColor?: boolean +} + const ESCAPE_TIMEOUT_MS = 50 const ANSI_PATTERN = /^\x1b\[[0-9;?]*[A-Za-z]/ @@ -94,6 +114,26 @@ function formatItemLabel(item: MenuItem, selected: boolean, useColor: b return truncateAnsi(labelText, maxWidth) } +export function parseKey(data: Buffer): KeyAction { + const s = data.toString() + if (s === "\x1b[A" || s === "\x1bOA") return "up" + if (s === "\x1b[B" || s === "\x1bOB") return "down" + if (s === "\r" || s === "\n") return "enter" + if (s === "\x03") return "escape" + if (s === "\x1b") return "escape-start" + return null +} + +export function isTTY(input: NodeJS.ReadStream = process.stdin, output: NodeJS.WriteStream = process.stdout): boolean { + return Boolean(input.isTTY && output.isTTY) +} + +export function shouldUseColor(): boolean { + const noColor = process.env.NO_COLOR + if (noColor && noColor !== "0") return false + return true +} + export async function select(items: MenuItem[], options: SelectOptions): Promise { const input = options.input ?? process.stdin const output = options.output ?? process.stdout @@ -187,7 +227,6 @@ export async function select(items: MenuItem[], options: SelectOptions): P if (error instanceof Error) { // best effort cleanup } - // best effort cleanup } process.removeListener("SIGINT", onSignal) @@ -258,10 +297,29 @@ export async function select(items: MenuItem[], options: SelectOptions): P input.on("data", onKey) } catch (error) { if (error instanceof Error) { - // fall back to null selection when terminal setup fails + // fall through to null result on non-interactive terminals } - cleanup() - resolve(null) + finishWithValue(null) } }) } + +export async function confirm(message: string, defaultYes = false, options: ConfirmOptions = {}): Promise { + const items = defaultYes + ? [ + { label: "Yes", value: true }, + { label: "No", value: false } + ] + : [ + { label: "No", value: false }, + { label: "Yes", value: true } + ] + + const result = await select(items, { + message, + input: options.input, + output: options.output, + useColor: options.useColor + }) + return result ?? false +} diff --git a/lib/ui/tty/ansi.ts b/lib/ui/tty/ansi.ts deleted file mode 100644 index fc56917..0000000 --- a/lib/ui/tty/ansi.ts +++ /dev/null @@ -1,35 +0,0 @@ -export const ANSI = { - hide: "\x1b[?25l", - show: "\x1b[?25h", - up: (n = 1) => `\x1b[${n}A`, - clearLine: "\x1b[2K", - cyan: "\x1b[36m", - green: "\x1b[32m", - red: "\x1b[31m", - yellow: "\x1b[33m", - dim: "\x1b[2m", - bold: "\x1b[1m", - reset: "\x1b[0m" -} as const - -export type KeyAction = "up" | "down" | "enter" | "escape" | "escape-start" | null - -export function parseKey(data: Buffer): KeyAction { - const s = data.toString() - if (s === "\x1b[A" || s === "\x1bOA") return "up" - if (s === "\x1b[B" || s === "\x1bOB") return "down" - if (s === "\r" || s === "\n") return "enter" - if (s === "\x03") return "escape" - if (s === "\x1b") return "escape-start" - return null -} - -export function isTTY(input: NodeJS.ReadStream = process.stdin, output: NodeJS.WriteStream = process.stdout): boolean { - return Boolean(input.isTTY && output.isTTY) -} - -export function shouldUseColor(): boolean { - const noColor = process.env.NO_COLOR - if (noColor && noColor !== "0") return false - return true -} diff --git a/lib/ui/tty/confirm.ts b/lib/ui/tty/confirm.ts deleted file mode 100644 index 247263b..0000000 --- a/lib/ui/tty/confirm.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { select } from "./select.js" - -export type ConfirmOptions = { - input?: NodeJS.ReadStream - output?: NodeJS.WriteStream - useColor?: boolean -} - -export async function confirm(message: string, defaultYes = false, options: ConfirmOptions = {}): Promise { - const items = defaultYes - ? [ - { label: "Yes", value: true }, - { label: "No", value: false } - ] - : [ - { label: "No", value: false }, - { label: "Yes", value: true } - ] - - const result = await select(items, { - message, - input: options.input, - output: options.output, - useColor: options.useColor - }) - return result ?? false -} diff --git a/package.json b/package.json index cf2224c..a0ba9a2 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "check:docs": "node scripts/check-docs-references.mjs", "verify": "npm run check:esm-imports && npm run lint && npm run format:check && npm run typecheck && npm run typecheck:test && npm run test:anti-mock && npm run test:coverage && npm run check:coverage-ratchet && npm run check:docs && npm run build && npm run check:dist-esm-imports && npm run smoke:cli:dist", "check:esm-imports": "node scripts/check-esm-import-specifiers.mjs", - "check:dist-esm-imports": "node scripts/check-dist-esm-import-specifiers.mjs", + "check:dist-esm-imports": "node scripts/check-esm-import-specifiers.mjs --dist", "smoke:cli": "npm run build && npm run smoke:cli:dist", "smoke:cli:dist": "node ./dist/bin/opencode-codex-auth.js --help", "perf:profile": "npm run build && node dist/scripts/perf-profile.js 300 .", diff --git a/scripts/check-dist-esm-import-specifiers.mjs b/scripts/check-dist-esm-import-specifiers.mjs deleted file mode 100644 index 76fc93e..0000000 --- a/scripts/check-dist-esm-import-specifiers.mjs +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env node - -import fs from "node:fs" -import path from "node:path" -import { findExtensionlessRelativeImportOffenders } from "./lib/esm-import-guard.mjs" - -const distDir = path.resolve("dist") -const allowedRuntimeExtensions = new Set([".js", ".json", ".node", ".mjs", ".cjs"]) - -if (!fs.existsSync(distDir) || !fs.statSync(distDir).isDirectory()) { - console.error("dist/ does not exist. Run npm run build first.") - process.exit(1) -} - -const files = [] -const stack = [distDir] -while (stack.length > 0) { - const dir = stack.pop() - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const entryPath = path.join(dir, entry.name) - if (entry.isDirectory()) { - stack.push(entryPath) - } else if ( - entry.isFile() && - (entryPath.endsWith(".js") || entryPath.endsWith(".mjs") || entryPath.endsWith(".cjs")) - ) { - files.push(entryPath) - } - } -} - -const offenders = [] - -for (const filePath of files) { - const content = fs.readFileSync(filePath, "utf8") - offenders.push(...findExtensionlessRelativeImportOffenders(filePath, content, allowedRuntimeExtensions)) -} - -if (offenders.length > 0) { - console.error("Found extensionless relative imports in dist output:") - for (const offender of offenders) { - console.error(`- ${offender.file}:${offender.line} -> ${offender.specifier}`) - } - process.exit(1) -} - -console.log("dist output uses fully specified relative import specifiers.") diff --git a/scripts/check-esm-import-specifiers.mjs b/scripts/check-esm-import-specifiers.mjs index 013a672..f445132 100644 --- a/scripts/check-esm-import-specifiers.mjs +++ b/scripts/check-esm-import-specifiers.mjs @@ -4,16 +4,28 @@ import fs from "node:fs" import path from "node:path" import { findExtensionlessRelativeImportOffenders, stripCommentsKeepLines } from "./lib/esm-import-guard.mjs" -const roots = ["index.ts", "lib", "bin"] +const isDistMode = process.argv.includes("--dist") +const roots = isDistMode ? ["dist"] : ["index.ts", "lib", "bin"] const sourceExtensions = new Set([".ts", ".mts"]) const allowedRuntimeExtensions = new Set([".js", ".json", ".node", ".mjs", ".cjs"]) +if (isDistMode) { + const distRoot = path.resolve("dist") + if (!fs.existsSync(distRoot) || !fs.statSync(distRoot).isDirectory()) { + console.error("dist output not found. Run `npm run build` before checking dist ESM imports.") + process.exit(1) + } +} + function collectFiles(rootPath) { const abs = path.resolve(rootPath) if (!fs.existsSync(abs)) return [] const stat = fs.statSync(abs) if (stat.isFile()) { + if (isDistMode) { + return allowedRuntimeExtensions.has(path.extname(abs)) ? [abs] : [] + } return sourceExtensions.has(path.extname(abs)) ? [abs] : [] } @@ -25,7 +37,12 @@ function collectFiles(rootPath) { const entryPath = path.join(dir, entry.name) if (entry.isDirectory()) { stack.push(entryPath) - } else if (entry.isFile() && sourceExtensions.has(path.extname(entryPath))) { + } else if ( + entry.isFile() && + (isDistMode + ? allowedRuntimeExtensions.has(path.extname(entryPath)) + : sourceExtensions.has(path.extname(entryPath))) + ) { files.push(entryPath) } } @@ -75,16 +92,26 @@ for (const root of roots) { for (const filePath of collectFiles(root)) { const content = fs.readFileSync(filePath, "utf8") offenders.push(...findExtensionlessRelativeImportOffenders(filePath, content, allowedRuntimeExtensions)) - collectToolImportViolations(filePath, content) + if (!isDistMode) { + collectToolImportViolations(filePath, content) + } } } if (offenders.length > 0) { - console.error("Found extensionless local ESM import specifiers:") + console.error( + isDistMode + ? "Found extensionless relative imports in dist output:" + : "Found extensionless local ESM import specifiers:" + ) for (const offender of offenders) { console.error(`- ${offender.file}:${offender.line} -> ${offender.specifier}`) } process.exit(1) } -console.log("All local ESM import specifiers are fully specified.") +console.log( + isDistMode + ? "dist output uses fully specified relative import specifiers." + : "All local ESM import specifiers are fully specified." +) diff --git a/scripts/coverage-ratchet.baseline.json b/scripts/coverage-ratchet.baseline.json index c9cde8f..1fe949e 100644 --- a/scripts/coverage-ratchet.baseline.json +++ b/scripts/coverage-ratchet.baseline.json @@ -97,10 +97,10 @@ "statements": 89.47 }, "lib/codex-native/catalog-sync.ts": { - "lines": 100, - "branches": 75, + "lines": 93.75, + "branches": 85, "functions": 100, - "statements": 100 + "statements": 93.75 }, "lib/codex-native/chat-hooks.ts": { "lines": 95.29, @@ -151,10 +151,10 @@ "statements": 100 }, "lib/codex-native/oauth-server.ts": { - "lines": 76.41, - "branches": 71.01, - "functions": 93.75, - "statements": 76.41 + "lines": 76.51, + "branches": 74.1, + "functions": 92, + "statements": 76.51 }, "lib/codex-native/oauth-utils.ts": { "lines": 92.66, @@ -217,10 +217,10 @@ "statements": 92.94 }, "lib/codex-native/request-transform-model.ts": { - "lines": 92.07, - "branches": 86.86, + "lines": 91.02, + "branches": 82.95, "functions": 100, - "statements": 92.07 + "statements": 91.02 }, "lib/codex-native/request-transform-payload-helpers.ts": { "lines": 95.59, @@ -324,6 +324,12 @@ "functions": 100, "statements": 100 }, + "lib/config/file.ts": { + "lines": 81.62, + "branches": 76.08, + "functions": 100, + "statements": 81.62 + }, "lib/config/io.ts": { "lines": 91.93, "branches": 72.72, @@ -558,6 +564,12 @@ "functions": 88.88, "statements": 86.19 }, + "lib/storage/auth-state.ts": { + "lines": 88.56, + "branches": 79.37, + "functions": 100, + "statements": 88.56 + }, "lib/storage/domain-state.ts": { "lines": 85.98, "branches": 81.73, @@ -589,10 +601,16 @@ "statements": 82.43 }, "lib/ui/auth-menu.ts": { - "lines": 61.46, - "branches": 75.92, - "functions": 73.68, - "statements": 61.46 + "lines": 93.71, + "branches": 68.1, + "functions": 100, + "statements": 93.71 + }, + "lib/ui/tty.ts": { + "lines": 76.44, + "branches": 67.21, + "functions": 94.11, + "statements": 76.44 }, "lib/ui/tty/ansi.ts": { "lines": 86.66, diff --git a/scripts/perf-profile.ts b/scripts/perf-profile.ts index 8b0f33d..5d69c6a 100644 --- a/scripts/perf-profile.ts +++ b/scripts/perf-profile.ts @@ -4,7 +4,7 @@ import path from "node:path" import { performance } from "node:perf_hooks" import { pathToFileURL } from "node:url" -type RequestTransformModule = typeof import("../lib/codex-native/request-transform.js") +type RequestTransformModule = typeof import("../lib/codex-native/request-transform-payload.js") type LoaderModule = typeof import("../lib/codex-native/openai-loader-fetch.js") type OrchestratorModule = typeof import("../lib/fetch-orchestrator.js") type RotationModule = typeof import("../lib/rotation.js") @@ -113,7 +113,7 @@ async function benchmarkPayloadTransforms( legacy: { medianMs: number; p95Ms: number } singlePass?: { medianMs: number; p95Ms: number } }> { - const mod = await importFromRoot(root, "dist/lib/codex-native/request-transform.js") + const mod = await importFromRoot(root, "dist/lib/codex-native/request-transform-payload.js") const legacyDurations: number[] = [] for (let i = 0; i < iterations; i += 1) { const t0 = performance.now() diff --git a/scripts/test-mocking-allowlist.json b/scripts/test-mocking-allowlist.json index c3981d6..84cdc6c 100644 --- a/scripts/test-mocking-allowlist.json +++ b/scripts/test-mocking-allowlist.json @@ -21,23 +21,13 @@ "mock": 0, "stubGlobal": 0 }, - "test/auth-menu-runner-delete-all.test.ts": { - "doMock": 1, - "mock": 0, - "stubGlobal": 0 - }, - "test/auth-menu-runner-scopes.test.ts": { - "doMock": 2, - "mock": 0, - "stubGlobal": 0 - }, - "test/catalog-auth.test.ts": { - "doMock": 2, + "test/auth-menu-runner.test.ts": { + "doMock": 3, "mock": 0, "stubGlobal": 0 }, "test/catalog-sync.test.ts": { - "doMock": 2, + "doMock": 3, "mock": 0, "stubGlobal": 0 }, diff --git a/test/auth-menu-flow.integration.test.ts b/test/auth-menu-flow.integration.test.ts index 7ef5489..bcf3ea7 100644 --- a/test/auth-menu-flow.integration.test.ts +++ b/test/auth-menu-flow.integration.test.ts @@ -8,7 +8,7 @@ let tmpDir: string | undefined const previousXdgConfigHome = process.env.XDG_CONFIG_HOME afterEach(async () => { - vi.doUnmock("../lib/ui/auth-menu-runner") + vi.doUnmock("../lib/ui/auth-menu") vi.resetModules() if (previousXdgConfigHome === undefined) { delete process.env.XDG_CONFIG_HOME @@ -71,7 +71,7 @@ describe("auth-menu flow integration", () => { } ) - vi.doMock("../lib/ui/auth-menu-runner", () => ({ + vi.doMock("../lib/ui/auth-menu", () => ({ runAuthMenuOnce })) diff --git a/test/auth-menu-runner-delete-all.test.ts b/test/auth-menu-runner-delete-all.test.ts deleted file mode 100644 index 1e1a523..0000000 --- a/test/auth-menu-runner-delete-all.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest" - -describe("auth menu runner delete-all", () => { - afterEach(() => { - vi.resetModules() - }) - - it("invokes delete-all handler from account-management submenu", async () => { - const account = { - index: 0, - identityKey: "acc_1|one@example.com|plus", - email: "one@example.com", - plan: "plus", - enabled: true, - authTypes: ["native"] as const - } - - vi.doMock("../lib/ui/auth-menu", () => ({ - showAuthMenu: vi.fn(async () => ({ type: "manage" })), - selectAccount: vi.fn(async () => account), - showAccountDetails: vi.fn(async () => ({ type: "delete-all", scope: "native" })) - })) - - const { runAuthMenuOnce } = await import("../lib/ui/auth-menu-runner") - - const onDeleteAll = vi.fn() - const result = await runAuthMenuOnce({ - accounts: [account], - handlers: { - onCheckQuotas: vi.fn(), - onConfigureModels: vi.fn(), - onDeleteAll, - onTransfer: vi.fn(), - onToggleAccount: vi.fn(), - onRefreshAccount: vi.fn(), - onDeleteAccount: vi.fn() - } - }) - - expect(result).toBe("continue") - expect(onDeleteAll).toHaveBeenCalledTimes(1) - expect(onDeleteAll).toHaveBeenCalledWith("native") - }) -}) diff --git a/test/auth-menu-runner-scopes.test.ts b/test/auth-menu-runner-scopes.test.ts deleted file mode 100644 index bb6ce6c..0000000 --- a/test/auth-menu-runner-scopes.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest" - -describe("auth menu runner scoped delete actions", () => { - afterEach(() => { - vi.resetModules() - }) - - it("passes scoped delete-all from the top-level menu", async () => { - vi.doMock("../lib/ui/auth-menu", () => ({ - showAuthMenu: vi.fn(async () => ({ type: "delete-all", scope: "codex" })), - selectAccount: vi.fn(async () => null), - showAccountDetails: vi.fn(async () => ({ type: "cancel" })) - })) - - const { runAuthMenuOnce } = await import("../lib/ui/auth-menu-runner") - const onDeleteAll = vi.fn() - - const result = await runAuthMenuOnce({ - accounts: [], - handlers: { - onCheckQuotas: vi.fn(), - onConfigureModels: vi.fn(), - onDeleteAll, - onTransfer: vi.fn(), - onToggleAccount: vi.fn(), - onRefreshAccount: vi.fn(), - onDeleteAccount: vi.fn() - } - }) - - expect(result).toBe("continue") - expect(onDeleteAll).toHaveBeenCalledWith("codex") - }) - - it("passes scoped account delete from account details", async () => { - const account = { - index: 0, - identityKey: "acc_1|one@example.com|plus", - email: "one@example.com", - plan: "plus", - enabled: true, - authTypes: ["native", "codex"] as const - } - - vi.doMock("../lib/ui/auth-menu", () => ({ - showAuthMenu: vi.fn(async () => ({ type: "select-account", account })), - selectAccount: vi.fn(async () => account), - showAccountDetails: vi.fn(async () => ({ type: "delete", scope: "codex" })) - })) - - const { runAuthMenuOnce } = await import("../lib/ui/auth-menu-runner") - const onDeleteAccount = vi.fn() - - const result = await runAuthMenuOnce({ - accounts: [account], - handlers: { - onCheckQuotas: vi.fn(), - onConfigureModels: vi.fn(), - onDeleteAll: vi.fn(), - onTransfer: vi.fn(), - onToggleAccount: vi.fn(), - onRefreshAccount: vi.fn(), - onDeleteAccount - } - }) - - expect(result).toBe("continue") - expect(onDeleteAccount).toHaveBeenCalledWith(account, "codex") - }) -}) diff --git a/test/auth-menu-runner.test.ts b/test/auth-menu-runner.test.ts index 6c36f2f..726286c 100644 --- a/test/auth-menu-runner.test.ts +++ b/test/auth-menu-runner.test.ts @@ -2,7 +2,7 @@ import { PassThrough } from "node:stream" import { describe, expect, it, vi } from "vitest" -import { runAuthMenuOnce } from "../lib/ui/auth-menu-runner" +import { runAuthMenuOnce } from "../lib/ui/auth-menu" async function tick(): Promise { await new Promise((resolve) => setImmediate(resolve)) @@ -96,4 +96,143 @@ describe("auth menu runner", () => { expect(result).toBe("continue") expect(onTransfer).toHaveBeenCalledTimes(1) }) + + it("passes scoped delete-all from the top-level menu", async () => { + const accounts = [ + { + index: 0, + identityKey: "acc_1|one@example.com|plus", + email: "one@example.com", + plan: "plus", + enabled: true, + authTypes: ["native", "codex"] as const + } + ] + + vi.doMock("../lib/ui/tty.js", async () => { + const actual = await vi.importActual("../lib/ui/tty.js") + return { + ...actual, + select: vi.fn().mockResolvedValueOnce({ type: "delete-all", scope: "both" }).mockResolvedValueOnce("codex"), + confirm: vi.fn(async () => true), + shouldUseColor: vi.fn(() => false) + } + }) + + vi.resetModules() + const { runAuthMenuOnce: runMockedAuthMenuOnce } = await import("../lib/ui/auth-menu") + const onDeleteAll = vi.fn() + + const result = await runMockedAuthMenuOnce({ + accounts, + handlers: { + onCheckQuotas: vi.fn(), + onConfigureModels: vi.fn(), + onDeleteAll, + onTransfer: vi.fn(), + onToggleAccount: vi.fn(), + onRefreshAccount: vi.fn(), + onDeleteAccount: vi.fn() + } + }) + + expect(result).toBe("continue") + expect(onDeleteAll).toHaveBeenCalledWith("codex") + vi.doUnmock("../lib/ui/tty.js") + vi.resetModules() + }) + + it("passes scoped account delete from account details", async () => { + const account = { + index: 0, + identityKey: "acc_1|one@example.com|plus", + email: "one@example.com", + plan: "plus", + enabled: true, + authTypes: ["native", "codex"] as const + } + + vi.doMock("../lib/ui/tty.js", async () => { + const actual = await vi.importActual("../lib/ui/tty.js") + return { + ...actual, + select: vi + .fn() + .mockResolvedValueOnce({ type: "select-account", account }) + .mockResolvedValueOnce({ type: "delete", scope: "codex" }), + confirm: vi.fn(async () => true), + shouldUseColor: vi.fn(() => false) + } + }) + + vi.resetModules() + const { runAuthMenuOnce: runMockedAuthMenuOnce } = await import("../lib/ui/auth-menu") + const onDeleteAccount = vi.fn() + + const result = await runMockedAuthMenuOnce({ + accounts: [account], + handlers: { + onCheckQuotas: vi.fn(), + onConfigureModels: vi.fn(), + onDeleteAll: vi.fn(), + onTransfer: vi.fn(), + onToggleAccount: vi.fn(), + onRefreshAccount: vi.fn(), + onDeleteAccount + } + }) + + expect(result).toBe("continue") + expect(onDeleteAccount).toHaveBeenCalledWith(account, "codex") + vi.doUnmock("../lib/ui/tty.js") + vi.resetModules() + }) + + it("invokes delete-all handler from account-management submenu", async () => { + const account = { + index: 0, + identityKey: "acc_1|one@example.com|plus", + email: "one@example.com", + plan: "plus", + enabled: true, + authTypes: ["native"] as const + } + + vi.doMock("../lib/ui/tty.js", async () => { + const actual = await vi.importActual("../lib/ui/tty.js") + return { + ...actual, + select: vi + .fn() + .mockResolvedValueOnce({ type: "manage" }) + .mockResolvedValueOnce(account) + .mockResolvedValueOnce({ type: "delete-all", scope: "native" }), + confirm: vi.fn(async () => true), + shouldUseColor: vi.fn(() => false) + } + }) + + vi.resetModules() + const { runAuthMenuOnce: runMockedAuthMenuOnce } = await import("../lib/ui/auth-menu") + const onDeleteAll = vi.fn() + + const result = await runMockedAuthMenuOnce({ + accounts: [account], + handlers: { + onCheckQuotas: vi.fn(), + onConfigureModels: vi.fn(), + onDeleteAll, + onTransfer: vi.fn(), + onToggleAccount: vi.fn(), + onRefreshAccount: vi.fn(), + onDeleteAccount: vi.fn() + } + }) + + expect(result).toBe("continue") + expect(onDeleteAll).toHaveBeenCalledTimes(1) + expect(onDeleteAll).toHaveBeenCalledWith("native") + vi.doUnmock("../lib/ui/tty.js") + vi.resetModules() + }) }) diff --git a/test/catalog-auth.test.ts b/test/catalog-auth.test.ts deleted file mode 100644 index 9f943e6..0000000 --- a/test/catalog-auth.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest" - -describe("catalog auth candidate selection", () => { - afterEach(() => { - vi.restoreAllMocks() - vi.resetModules() - vi.unmock("../lib/storage.js") - vi.unmock("../lib/rotation.js") - }) - - function mockCatalogSelection(selected: { access?: string; accountId?: string; expires?: number }) { - const loadAuthStorage = vi.fn(async () => ({})) - const getOpenAIOAuthDomain = vi.fn(() => ({ - strategy: "round_robin" as const, - accounts: [{ identityKey: "a" }] - })) - const selectAccount = vi.fn(() => selected) - - vi.doMock("../lib/storage.js", () => ({ - loadAuthStorage, - getOpenAIOAuthDomain - })) - vi.doMock("../lib/rotation.js", () => ({ - selectAccount - })) - } - - it("falls back to accountId when selected token expiry is zero or non-finite", async () => { - vi.spyOn(Date, "now").mockReturnValue(1_000) - mockCatalogSelection({ - access: "token", - accountId: "acc_1", - expires: 0 - }) - - const { selectCatalogAuthCandidate } = await import("../lib/codex-native/catalog-auth.js") - await expect(selectCatalogAuthCandidate("native", false)).resolves.toEqual({ accountId: "acc_1" }) - - vi.resetModules() - mockCatalogSelection({ - access: "token", - accountId: "acc_2", - expires: Number.NaN - }) - const { selectCatalogAuthCandidate: selectCatalogAuthCandidateAgain } = await import( - "../lib/codex-native/catalog-auth.js" - ) - await expect(selectCatalogAuthCandidateAgain("native", false)).resolves.toEqual({ accountId: "acc_2" }) - }) - - it("returns access token only for finite future expiry", async () => { - vi.spyOn(Date, "now").mockReturnValue(1_000) - mockCatalogSelection({ - access: "token", - accountId: "acc_ok", - expires: 5_000 - }) - - const { selectCatalogAuthCandidate } = await import("../lib/codex-native/catalog-auth.js") - await expect(selectCatalogAuthCandidate("native", false)).resolves.toEqual({ - accessToken: "token", - accountId: "acc_ok" - }) - }) -}) diff --git a/test/catalog-sync.test.ts b/test/catalog-sync.test.ts index 244e995..395e32f 100644 --- a/test/catalog-sync.test.ts +++ b/test/catalog-sync.test.ts @@ -1,12 +1,19 @@ import { afterEach, describe, expect, it, vi } from "vitest" const catalogSyncMocks = vi.hoisted(() => ({ - selectCatalogAuthCandidate: vi.fn(), - getCodexModelCatalog: vi.fn() + getCodexModelCatalog: vi.fn(), + loadAuthStorage: vi.fn(async () => ({})), + getOpenAIOAuthDomain: vi.fn(), + selectAccount: vi.fn() })) -vi.doMock("../lib/codex-native/catalog-auth.js", () => ({ - selectCatalogAuthCandidate: catalogSyncMocks.selectCatalogAuthCandidate +vi.doMock("../lib/storage.js", () => ({ + loadAuthStorage: catalogSyncMocks.loadAuthStorage, + getOpenAIOAuthDomain: catalogSyncMocks.getOpenAIOAuthDomain +})) + +vi.doMock("../lib/rotation.js", () => ({ + selectAccount: catalogSyncMocks.selectAccount })) vi.doMock("../lib/model-catalog.js", () => ({ @@ -16,14 +23,22 @@ vi.doMock("../lib/model-catalog.js", () => ({ describe("catalog sync", () => { afterEach(() => { vi.resetModules() - catalogSyncMocks.selectCatalogAuthCandidate.mockReset() catalogSyncMocks.getCodexModelCatalog.mockReset() + catalogSyncMocks.loadAuthStorage.mockReset() + catalogSyncMocks.getOpenAIOAuthDomain.mockReset() + catalogSyncMocks.selectAccount.mockReset() }) it("bootstraps with selected auth candidate and applies refreshed catalogs", async () => { - catalogSyncMocks.selectCatalogAuthCandidate.mockResolvedValue({ - accessToken: "seed-token", - accountId: "acc_seed" + catalogSyncMocks.getOpenAIOAuthDomain.mockReturnValue({ + strategy: "sticky", + activeIdentityKey: "acc_seed", + accounts: [{ identityKey: "acc_seed" }] + }) + catalogSyncMocks.selectAccount.mockReturnValue({ + access: "seed-token", + accountId: "acc_seed", + expires: Date.now() + 10_000 }) catalogSyncMocks.getCodexModelCatalog .mockResolvedValueOnce([{ slug: "gpt-5.3-codex" }]) @@ -47,7 +62,13 @@ describe("catalog sync", () => { activateCatalogScope }) - expect(catalogSyncMocks.selectCatalogAuthCandidate).toHaveBeenCalledWith("native", false, "sticky") + expect(catalogSyncMocks.loadAuthStorage).toHaveBeenCalledTimes(1) + expect(catalogSyncMocks.selectAccount).toHaveBeenCalledWith( + expect.objectContaining({ + strategy: "sticky", + stickyPidOffset: false + }) + ) expect(catalogSyncMocks.getCodexModelCatalog).toHaveBeenCalledTimes(1) expect(catalogSyncMocks.getCodexModelCatalog).toHaveBeenCalledWith( expect.objectContaining({ @@ -78,9 +99,15 @@ describe("catalog sync", () => { }) it("clears the applied catalog when a refresh returns undefined", async () => { - catalogSyncMocks.selectCatalogAuthCandidate.mockResolvedValue({ - accessToken: "seed-token", - accountId: "acc_seed" + catalogSyncMocks.getOpenAIOAuthDomain.mockReturnValue({ + strategy: "sticky", + activeIdentityKey: "acc_seed", + accounts: [{ identityKey: "acc_seed" }] + }) + catalogSyncMocks.selectAccount.mockReturnValue({ + access: "seed-token", + accountId: "acc_seed", + expires: Date.now() + 10_000 }) catalogSyncMocks.getCodexModelCatalog .mockResolvedValueOnce([{ slug: "gpt-5.3-codex" }]) @@ -112,9 +139,15 @@ describe("catalog sync", () => { }) it("does not reactivate another scope when a background refresh completes", async () => { - catalogSyncMocks.selectCatalogAuthCandidate.mockResolvedValue({ - accessToken: "seed-token", - accountId: "acc_seed" + catalogSyncMocks.getOpenAIOAuthDomain.mockReturnValue({ + strategy: "sticky", + activeIdentityKey: "acc_seed", + accounts: [{ identityKey: "acc_seed" }] + }) + catalogSyncMocks.selectAccount.mockReturnValue({ + access: "seed-token", + accountId: "acc_seed", + expires: Date.now() + 10_000 }) catalogSyncMocks.getCodexModelCatalog .mockResolvedValueOnce([{ slug: "gpt-5.3-codex" }]) @@ -142,4 +175,54 @@ describe("catalog sync", () => { expect(setCatalogModels).toHaveBeenNthCalledWith(2, "account:acc_b", [{ slug: "gpt-5.4-codex" }]) expect(activateCatalogScope).toHaveBeenCalledTimes(1) }) + + it("falls back to accountId when selected token expiry is zero or non-finite", async () => { + vi.spyOn(Date, "now").mockReturnValue(1_000) + catalogSyncMocks.getOpenAIOAuthDomain.mockReturnValue({ + strategy: "round_robin", + accounts: [{ identityKey: "a" }] + }) + catalogSyncMocks.selectAccount.mockReturnValueOnce({ + access: "token", + accountId: "acc_1", + expires: 0 + }) + + const { selectCatalogAuthCandidate } = await import("../lib/codex-native/catalog-sync.js") + await expect(selectCatalogAuthCandidate("native", false)).resolves.toEqual({ accountId: "acc_1" }) + + vi.resetModules() + catalogSyncMocks.getOpenAIOAuthDomain.mockReturnValue({ + strategy: "round_robin", + accounts: [{ identityKey: "a" }] + }) + catalogSyncMocks.selectAccount.mockReturnValueOnce({ + access: "token", + accountId: "acc_2", + expires: Number.NaN + }) + const { selectCatalogAuthCandidate: selectCatalogAuthCandidateAgain } = await import( + "../lib/codex-native/catalog-sync.js" + ) + await expect(selectCatalogAuthCandidateAgain("native", false)).resolves.toEqual({ accountId: "acc_2" }) + }) + + it("returns access token only for finite future expiry", async () => { + vi.spyOn(Date, "now").mockReturnValue(1_000) + catalogSyncMocks.getOpenAIOAuthDomain.mockReturnValue({ + strategy: "round_robin", + accounts: [{ identityKey: "a" }] + }) + catalogSyncMocks.selectAccount.mockReturnValue({ + access: "token", + accountId: "acc_ok", + expires: 5_000 + }) + + const { selectCatalogAuthCandidate } = await import("../lib/codex-native/catalog-sync.js") + await expect(selectCatalogAuthCandidate("native", false)).resolves.toEqual({ + accessToken: "token", + accountId: "acc_ok" + }) + }) }) diff --git a/test/check-dist-esm-import-specifiers.test.ts b/test/check-dist-esm-import-specifiers.test.ts index 15c2ef3..4b9c470 100644 --- a/test/check-dist-esm-import-specifiers.test.ts +++ b/test/check-dist-esm-import-specifiers.test.ts @@ -5,16 +5,41 @@ import { spawnSync } from "node:child_process" import { describe, expect, it } from "vitest" -const script = path.resolve(process.cwd(), "scripts/check-dist-esm-import-specifiers.mjs") +const script = path.resolve(process.cwd(), "scripts/check-esm-import-specifiers.mjs") describe("check-dist-esm-import-specifiers script", () => { + it("fails when dist output is missing", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-dist-esm-guard-")) + + const result = spawnSync("node", [script, "--dist"], { + cwd: root, + encoding: "utf8" + }) + + expect(result.status).toBe(1) + expect(result.stderr).toContain("Run `npm run build`") + }) + + it("fails when dist exists as a plain file", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-dist-esm-guard-")) + await fs.writeFile(path.join(root, "dist"), "not a directory\n", "utf8") + + const result = spawnSync("node", [script, "--dist"], { + cwd: root, + encoding: "utf8" + }) + + expect(result.status).toBe(1) + expect(result.stderr).toContain("Run `npm run build`") + }) + it("fails for extensionless side-effect imports in dist output", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-dist-esm-guard-")) await fs.mkdir(path.join(root, "dist", "lib"), { recursive: true }) await fs.writeFile(path.join(root, "dist", "index.js"), 'import "./lib/side-effect"\n', "utf8") await fs.writeFile(path.join(root, "dist", "lib", "side-effect.js"), "export {}\n", "utf8") - const result = spawnSync("node", [script], { + const result = spawnSync("node", [script, "--dist"], { cwd: root, encoding: "utf8" }) @@ -30,7 +55,7 @@ describe("check-dist-esm-import-specifiers script", () => { await fs.writeFile(path.join(root, "dist", "index.js"), 'void 0;import "./lib/side-effect"\n', "utf8") await fs.writeFile(path.join(root, "dist", "lib", "side-effect.js"), "export {}\n", "utf8") - const result = spawnSync("node", [script], { + const result = spawnSync("node", [script, "--dist"], { cwd: root, encoding: "utf8" }) @@ -45,7 +70,7 @@ describe("check-dist-esm-import-specifiers script", () => { await fs.writeFile(path.join(root, "dist", "index.js"), 'import "./lib/side-effect.js"\n', "utf8") await fs.writeFile(path.join(root, "dist", "lib", "side-effect.js"), "export {}\n", "utf8") - const result = spawnSync("node", [script], { + const result = spawnSync("node", [script, "--dist"], { cwd: root, encoding: "utf8" }) @@ -60,7 +85,7 @@ describe("check-dist-esm-import-specifiers script", () => { await fs.writeFile(path.join(root, "dist", "index.js"), '// import "./lib/side-effect"\n', "utf8") await fs.writeFile(path.join(root, "dist", "lib", "side-effect.js"), "export {}\n", "utf8") - const result = spawnSync("node", [script], { + const result = spawnSync("node", [script, "--dist"], { cwd: root, encoding: "utf8" }) @@ -75,7 +100,7 @@ describe("check-dist-esm-import-specifiers script", () => { await fs.writeFile(path.join(root, "dist", "index.js"), '/* import "./lib/side-effect" */\n', "utf8") await fs.writeFile(path.join(root, "dist", "lib", "side-effect.js"), "export {}\n", "utf8") - const result = spawnSync("node", [script], { + const result = spawnSync("node", [script, "--dist"], { cwd: root, encoding: "utf8" }) @@ -90,7 +115,7 @@ describe("check-dist-esm-import-specifiers script", () => { await fs.writeFile(path.join(root, "dist", "index.mjs"), 'import "./lib/side-effect"\n', "utf8") await fs.writeFile(path.join(root, "dist", "lib", "side-effect.mjs"), "export {}\n", "utf8") - const result = spawnSync("node", [script], { + const result = spawnSync("node", [script, "--dist"], { cwd: root, encoding: "utf8" }) @@ -105,7 +130,7 @@ describe("check-dist-esm-import-specifiers script", () => { await fs.writeFile(path.join(root, "dist", "index.js"), '// import { x } from "./lib/side-effect"\n', "utf8") await fs.writeFile(path.join(root, "dist", "lib", "side-effect.js"), "export const x = 1\n", "utf8") - const result = spawnSync("node", [script], { + const result = spawnSync("node", [script, "--dist"], { cwd: root, encoding: "utf8" }) @@ -124,7 +149,7 @@ describe("check-dist-esm-import-specifiers script", () => { ) await fs.writeFile(path.join(root, "dist", "lib", "side-effect.js"), "export const x = 1\n", "utf8") - const result = spawnSync("node", [script], { + const result = spawnSync("node", [script, "--dist"], { cwd: root, encoding: "utf8" }) @@ -143,7 +168,7 @@ describe("check-dist-esm-import-specifiers script", () => { ) await fs.writeFile(path.join(root, "dist", "lib", "side-effect.js"), "export const x = 1\n", "utf8") - const result = spawnSync("node", [script], { + const result = spawnSync("node", [script, "--dist"], { cwd: root, encoding: "utf8" }) @@ -162,7 +187,7 @@ describe("check-dist-esm-import-specifiers script", () => { ) await fs.writeFile(path.join(root, "dist", "lib", "side-effect.js"), "export {}\n", "utf8") - const result = spawnSync("node", [script], { + const result = spawnSync("node", [script, "--dist"], { cwd: root, encoding: "utf8" }) diff --git a/test/codex-native-auth-menu-wiring.test.ts b/test/codex-native-auth-menu-wiring.test.ts index 480d3e8..93c8ccf 100644 --- a/test/codex-native-auth-menu-wiring.test.ts +++ b/test/codex-native-auth-menu-wiring.test.ts @@ -60,7 +60,7 @@ async function loadPluginWithMenu(input: { } } as Record) - vi.doMock("../lib/ui/auth-menu-runner", () => ({ + vi.doMock("../lib/ui/auth-menu", () => ({ runAuthMenuOnce })) diff --git a/test/codex-native-request-transform.test.ts b/test/codex-native-request-transform.test.ts index caec8a8..b8efae6 100644 --- a/test/codex-native-request-transform.test.ts +++ b/test/codex-native-request-transform.test.ts @@ -8,7 +8,7 @@ import { stripReasoningReplayFromRequest, stripStaleCatalogScopedDefaultsFromRequest, transformOutboundRequestPayload -} from "../lib/codex-native/request-transform" +} from "../lib/codex-native/request-transform-payload.js" describe("codex request role remap", () => { it("remaps non-permissions developer messages to user", async () => { diff --git a/test/config-getters.test.ts b/test/config-getters.test.ts index 3894e0c..ea71fc9 100644 --- a/test/config-getters.test.ts +++ b/test/config-getters.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest" import { + buildResolvedBehaviorSettings, + cloneBehaviorSettings, getCollaborationProfileEnabled, getCodexCompactionOverrideEnabled, getCompatInputSanitizerEnabled, @@ -35,6 +37,35 @@ describe("config", () => { expect(getProactiveRefreshBufferMs(cfg)).toBe(60_000) }) + it("keeps behavior helpers on the top-level config export surface", () => { + const fileBehavior = { + global: { + personality: "balanced", + thinkingSummaries: false + } + } as const + + const cloned = cloneBehaviorSettings(fileBehavior) + expect(cloned).toEqual(fileBehavior) + expect(cloned).not.toBe(fileBehavior) + + expect( + buildResolvedBehaviorSettings({ + fileBehavior, + envPersonality: "concise", + envThinkingSummaries: undefined, + envVerbosityEnabled: undefined, + envVerbosity: undefined, + envServiceTier: undefined + }) + ).toEqual({ + global: { + personality: "concise", + thinkingSummaries: false + } + }) + }) + it("clamps and floors buffer", () => { expect(getProactiveRefreshBufferMs({ proactiveRefreshBufferMs: -500 })).toBe(0) expect(getProactiveRefreshBufferMs({ proactiveRefreshBufferMs: 1234.56 })).toBe(1234) diff --git a/test/prompt-cache-key.test.ts b/test/prompt-cache-key.test.ts index 1ad8d18..342128c 100644 --- a/test/prompt-cache-key.test.ts +++ b/test/prompt-cache-key.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest" import { buildProjectPromptCacheKey, PROMPT_CACHE_KEY_VERSION } from "../lib/prompt-cache-key" -import { applyPromptCacheKeyOverrideToRequest } from "../lib/codex-native/request-transform" +import { applyPromptCacheKeyOverrideToRequest } from "../lib/codex-native/request-transform-payload.js" describe("prompt cache key", () => { it("builds a stable versioned key per project path and mode", () => { diff --git a/test/release-hygiene.test.ts b/test/release-hygiene.test.ts index 7856d12..1220097 100644 --- a/test/release-hygiene.test.ts +++ b/test/release-hygiene.test.ts @@ -37,7 +37,7 @@ describe("release hygiene", () => { searchFrom = nextIndex + step.length } expect(pkg.scripts?.["check:esm-imports"]).toBe("node scripts/check-esm-import-specifiers.mjs") - expect(pkg.scripts?.["check:dist-esm-imports"]).toBe("node scripts/check-dist-esm-import-specifiers.mjs") + expect(pkg.scripts?.["check:dist-esm-imports"]).toBe("node scripts/check-esm-import-specifiers.mjs --dist") expect(pkg.scripts?.["smoke:cli:dist"]).toBe("node ./dist/bin/opencode-codex-auth.js --help") expect(pkg.scripts?.["test:coverage"]).toBe("vitest run --coverage.enabled true --coverage.provider=v8") expect(pkg.scripts?.["patch:plugin-dts"]).toBe("node scripts/patch-opencode-plugin-dts.js") @@ -49,7 +49,7 @@ describe("release hygiene", () => { expect(pkg.scripts?.["clean:dist"]).toBe("node scripts/clean-dist.js") expect(existsSync(join(process.cwd(), "scripts", "clean-dist.js"))).toBe(true) expect(existsSync(join(process.cwd(), "scripts", "check-esm-import-specifiers.mjs"))).toBe(true) - expect(existsSync(join(process.cwd(), "scripts", "check-dist-esm-import-specifiers.mjs"))).toBe(true) + expect(existsSync(join(process.cwd(), "scripts", "check-dist-esm-import-specifiers.mjs"))).toBe(false) expect(existsSync(join(process.cwd(), "scripts", "check-file-size.mjs"))).toBe(false) expect(existsSync(join(process.cwd(), "scripts", "file-size-allowlist.json"))).toBe(false) }) diff --git a/test/request-transform-gpt54-long-context.test.ts b/test/request-transform-gpt54-long-context.test.ts index ee53ad6..fde7eb7 100644 --- a/test/request-transform-gpt54-long-context.test.ts +++ b/test/request-transform-gpt54-long-context.test.ts @@ -4,7 +4,7 @@ import type { BehaviorSettings } from "../lib/config.js" import { sanitizeOutboundRequestIfNeeded, transformOutboundRequestPayload -} from "../lib/codex-native/request-transform.js" +} from "../lib/codex-native/request-transform-payload.js" const PRIORITY_BEHAVIOR_SETTINGS: BehaviorSettings = { global: { diff --git a/test/request-transform-model-service-tier.test.ts b/test/request-transform-model-service-tier.test.ts index 00340ae..0fd49cc 100644 --- a/test/request-transform-model-service-tier.test.ts +++ b/test/request-transform-model-service-tier.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest" -import { getModelServiceTierOverride, resolveServiceTierForModel } from "../lib/codex-native/request-transform.js" +import { + getModelServiceTierOverride, + resolveServiceTierForModel +} from "../lib/codex-native/request-transform-model-service-tier.js" describe("request transform model service tier resolution", () => { it("passes configured global service tiers through without client-side model gating", () => { diff --git a/test/ui-tty-select.test.ts b/test/ui-tty-select.test.ts index 400c28f..f0d012c 100644 --- a/test/ui-tty-select.test.ts +++ b/test/ui-tty-select.test.ts @@ -2,9 +2,7 @@ import { EventEmitter } from "node:events" import { afterEach, describe, expect, it, vi } from "vitest" -import { confirm } from "../lib/ui/tty/confirm.js" -import { ANSI, parseKey } from "../lib/ui/tty/ansi.js" -import { select } from "../lib/ui/tty/select.js" +import { ANSI, confirm, parseKey, select } from "../lib/ui/tty.js" type MockInput = NodeJS.ReadStream & { isTTY: boolean