diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml deleted file mode 100644 index 7fbdc7b..0000000 --- a/.github/workflows/dependency-review.yml +++ /dev/null @@ -1,90 +0,0 @@ -name: Dependency Review - -on: - pull_request: - -permissions: - contents: read - -jobs: - dependency-review: - name: Dependency Review - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Review dependency changes - env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - GITHUB_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - - API_URL="https://api.github.com/repos/${GITHUB_REPOSITORY}/dependency-graph/compare/${BASE_SHA}...${HEAD_SHA}" - RESPONSE_PATH="${RUNNER_TEMP}/dependency-review.json" - - HTTP_STATUS="$( - curl --silent --show-error --location \ - --output "${RESPONSE_PATH}" \ - --write-out "%{http_code}" \ - --header "Accept: application/vnd.github+json" \ - --header "Authorization: Bearer ${GITHUB_TOKEN}" \ - --header "X-GitHub-Api-Version: 2022-11-28" \ - "${API_URL}" - )" - - case "${HTTP_STATUS}" in - 200) - ;; - 403|404) - echo "Dependency review is unavailable because GitHub Dependency Graph is disabled or inaccessible." >&2 - echo "Enable it at: https://github.com/${GITHUB_REPOSITORY}/settings/security_analysis" >&2 - exit 1 - ;; - *) - echo "Dependency review request failed with HTTP ${HTTP_STATUS}." >&2 - cat "${RESPONSE_PATH}" >&2 - exit 1 - ;; - esac - - RESPONSE_PATH="${RESPONSE_PATH}" node <<'EOF' - const fs = require("node:fs") - - const severityRank = new Map([ - ["critical", 4], - ["high", 3], - ["moderate", 2], - ["medium", 2], - ["low", 1] - ]) - - const payload = JSON.parse(fs.readFileSync(process.env.RESPONSE_PATH, "utf8")) - const vulnerabilities = [] - - for (const change of Array.isArray(payload) ? payload : []) { - for (const vulnerability of Array.isArray(change.vulnerabilities) ? change.vulnerabilities : []) { - const severity = String(vulnerability.severity ?? "").toLowerCase() - if ((severityRank.get(severity) ?? 0) < severityRank.get("high")) continue - vulnerabilities.push({ - severity, - name: change.name ?? "unknown-package", - manifest: change.manifest ?? "unknown-manifest", - summary: vulnerability.advisory_ghsa_id ?? vulnerability.advisory_summary ?? "unspecified advisory" - }) - } - } - - if (vulnerabilities.length === 0) { - console.log("Dependency review OK. No high-severity dependency changes found.") - process.exit(0) - } - - console.error("High-severity dependency changes detected:") - for (const vulnerability of vulnerabilities) { - console.error( - `- ${vulnerability.severity}: ${vulnerability.name} in ${vulnerability.manifest} (${vulnerability.summary})` - ) - } - process.exit(1) - EOF diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 49688a1..5585afb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,7 @@ The commit hook accepts staged-only commit-ready changes, while the push hook re `npm run verify:local` is the recommended manual gate. It runs `npm run verify`, but skips reruns when the current tree already passed locally. -Pull request GitHub CI keeps only hosted-value checks: clean-room verify, Linux tarball smoke, Windows smoke, dependency review, and secret scanning. `npm audit` still runs in GitHub, but only on default-branch pushes rather than every PR. +Pull request GitHub CI keeps only hosted-value checks: clean-room verify, Linux tarball smoke, Windows smoke, and secret scanning. `npm audit` still runs in GitHub, but only on default-branch pushes rather than every PR. `npm run verify` is the baseline full gate and runs: diff --git a/README.md b/README.md index 2bf7fd3..7b5644a 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ npm run check:docs Local git hooks now enforce `npm run verify` before both `git commit` and `git push`. The commit hook accepts staged-only commit-ready changes, and the push hook requires a clean tree so it verifies the exact commits being pushed. `npm run verify:local` runs the same enforcement manually, with a cache so unchanged trees do not rerun the full suite twice in a row. -Pull request CI stays intentionally lean: GitHub still runs clean-room verify, tarball smoke, Windows smoke, dependency review, and secret scanning. Dependency vulnerability auditing via `npm audit` now runs on default-branch pushes instead of every PR. +Pull request CI stays intentionally lean: GitHub still runs clean-room verify, tarball smoke, Windows smoke, and secret scanning. Dependency vulnerability auditing via `npm audit` runs on default-branch pushes instead of every PR. ## Usage Note diff --git a/docs/development/TESTING.md b/docs/development/TESTING.md index 1499233..e14504b 100644 --- a/docs/development/TESTING.md +++ b/docs/development/TESTING.md @@ -20,7 +20,7 @@ npm run verify `npm run verify` is the required local gate before commits, pushes, and PR updates. `npm run verify:local` runs that gate with caching, and the installed git hooks enforce it automatically before `git commit` and `git push`. The commit hook accepts staged-only commit-ready changes; the push hook requires a clean tree and derives the touched-file set from the outgoing commits so the local regression-only ratchet matches the actual push surface instead of only `HEAD^`. GitHub Actions still adds extra platform and security jobs beyond the repo-local verify run. -PR GitHub CI is intentionally slimmer than local `verify`: it keeps the clean-room Ubuntu verify job, Linux tarball smoke, Windows smoke, dependency review, and secret scanning. The dependency-review job now fails closed if GitHub Dependency Graph is unavailable so PRs do not silently lose that protection. The separate `npm audit` dependency audit remains GitHub-hosted, but it now runs on default-branch pushes instead of every PR. +PR GitHub CI is intentionally slimmer than local `verify`: it keeps the clean-room Ubuntu verify job, Linux tarball smoke, Windows smoke, and secret scanning. The separate `npm audit` dependency audit remains GitHub-hosted, but it now runs on default-branch pushes instead of every PR. It now includes strict Biome linting + format checks (including typed promise-safety rules), anti-mock policy checks, a regression-only coverage ratchet, docs drift checks, Node ESM regression checks (source + dist import specifiers), and a built CLI smoke run. diff --git a/docs/development/UPSTREAM_SYNC.md b/docs/development/UPSTREAM_SYNC.md index 8acf6c1..9e8a34b 100644 --- a/docs/development/UPSTREAM_SYNC.md +++ b/docs/development/UPSTREAM_SYNC.md @@ -4,25 +4,28 @@ Track the OpenCode and Codex releases this plugin is aligned to, and how to keep ## Current baseline -- OpenCode release: `v1.2.18` +- OpenCode release: `v1.3.0` - Upstream repo: `https://github.com/anomalyco/opencode` - Baseline tag commit: tracked in `docs/development/upstream-watch.json` - Upstream HEAD inspected: GitHub latest release/tag via `npm run check:upstream` - Native Codex reference file: `packages/opencode/src/plugin/codex.ts` - Codex upstream repo: `https://github.com/openai/codex` -- Codex upstream release track: `rust-v0.111.0` +- Codex upstream release track: `rust-v0.116.0` - Local dependency target: - - `@opencode-ai/plugin`: `^1.2.18` - - `@opencode-ai/sdk`: `^1.2.18` + - `@opencode-ai/plugin`: `^1.3.0` + - `@opencode-ai/sdk`: `^1.3.0` -## Latest parity audit (2026-03-05) +## Latest parity audit (2026-03-23) - Verified OAuth constants and authorize URL semantics against upstream `codex.ts`. - Verified native callback URI now uses `http://localhost:1455/auth/callback`. - Verified native headless device-auth requests use `User-Agent: opencode/`. - Verified request routing parity for `/v1/responses` and `/chat/completions` to Codex responses endpoint. -- Verified live Codex model payload now includes `gpt-5.4`, `gpt-5.3-codex-spark`, `display_name`, `priority`, and `supports_parallel_tool_calls`. -- Verified GPT-5.4 fast mode is represented by request-body `service_tier: "priority"`. +- Verified live Codex model payload now includes `default_reasoning_summary` and newer GPT-5.4-era catalog metadata in addition to `display_name`, `priority`, and `supports_parallel_tool_calls`. +- Verified default prompt caching remains upstream-owned: OpenCode sets `promptCacheKey` from `sessionID`, and Codex sends `prompt_cache_key` from the active conversation ID. +- Verified GPT-5.4 fast mode remains request-body `service_tier: "priority"`; no new HTTP priority header is used on the normal request path. +- Verified normal HTTP request parity for both `native` and `codex` modes continues to use `session_id` and excludes websocket-only `OpenAI-Beta` headers. +- Verified the plugin now uses Codex `default_reasoning_summary` instead of treating `reasoning_summary_format` as the default summary value. - Parity tests live in `test/codex-native-oauth-parity.test.ts`. ## Sync checklist diff --git a/docs/development/upstream-watch.json b/docs/development/upstream-watch.json index 42674a6..f6af9d5 100644 --- a/docs/development/upstream-watch.json +++ b/docs/development/upstream-watch.json @@ -3,42 +3,42 @@ { "id": "opencode", "repo": "anomalyco/opencode", - "baselineTag": "v1.2.18", - "updatedAt": "2026-03-05T19:28:23.006Z", + "baselineTag": "v1.3.0", + "updatedAt": "2026-03-23T16:25:00.338Z", "files": [ { "path": "packages/opencode/src/plugin/codex.ts", - "sha256": "b8b9d2ee27b4eafd0971d3a9c213b4c9a6ded050d7e3a5ec2aa94872de7f2fa5", + "sha256": "2f7ddda4b6d8619c883ac227832614f0c2a4dac2cabfe31c737fcd006666752e", "localArea": "lib/codex-native/", "reason": "Codex OAuth/auth headers/request routing parity" }, { "path": "packages/opencode/src/plugin/index.ts", - "sha256": "826c32adf6e9f570758bda353cc969066f81980092e3e2c38ec8c9681b4a2212", + "sha256": "c3aa395685bceeae29b93b1eb740e762f4449a6834fcc2e6863dd9d1a53a0abe", "localArea": "index.ts", "reason": "Built-in plugin boot order affects auth integration behavior" }, { "path": "packages/opencode/src/provider/provider.ts", - "sha256": "b0e044c68bd64494fe8b1da577e048154a63d7998ee07c685db8da42c8028544", + "sha256": "53bd3146d0ad5c06f5ae62d9e4c088385c221ab31e8f6a6bba15144dfe5b737c", "localArea": "lib/model-catalog.ts", "reason": "OpenAI model surface and provider model wiring parity" }, { "path": "packages/opencode/src/provider/auth.ts", - "sha256": "8667a7fbc2ed4a8f7a1a98630b541118a4dd67578db0824b81f947b8f7ba42c5", + "sha256": "ec8be328fcfe43b9129949ceaf93acc6e30f4daaf9c3e280c5ac64b4419e08c4", "localArea": "lib/storage.ts", "reason": "OpenAI auth structure/parsing parity signals" }, { "path": "packages/opencode/src/provider/transform.ts", - "sha256": "1f3fb8cd7761dd11fd35b48a90e72e7c3d16df22d84df45dba3734518c53db59", + "sha256": "398f78dde2876aa4ba2e23a35a35d6d6cbff663db033c4eb69715c363d683134", "localArea": "lib/codex-native/request-transform*.ts", "reason": "Request transform behavior changes affecting auth/model requests" }, { "path": "packages/opencode/src/provider/error.ts", - "sha256": "b7977ca752896f7096b546ee3e0c65a4b06ea646d7d417738d36f2eb71bb654e", + "sha256": "23be550ec590ac18b10bf2b6c00abdf68badc2c5e207926da59869aaf4b1c65c", "localArea": "lib/codex-native/openai-loader-fetch.ts", "reason": "OpenAI/Codex error semantics and retry-stop signals" }, @@ -50,7 +50,7 @@ }, { "path": "packages/opencode/src/session/message-v2.ts", - "sha256": "f796a51c9dbaee650295bea267c662197ad413ac6f5f678621c5b5c3eca43d22", + "sha256": "1d3c1d014c398cec06fb95e81fa5d2961370e548afa1784886d13fe6e94ee332", "localArea": "lib/fetch-orchestrator.ts", "reason": "Streaming error handling and retry behavior parity" } @@ -59,36 +59,36 @@ { "id": "codex-rs", "repo": "openai/codex", - "baselineTag": "rust-v0.111.0", - "updatedAt": "2026-03-05T19:28:23.656Z", + "baselineTag": "rust-v0.116.0", + "updatedAt": "2026-03-23T16:25:00.407Z", "files": [ { "path": "codex-rs/core/models.json", - "sha256": "9ce0f440d5aecd193212cae9244c03faaf8a6de4e71dac0a5186a1439647c788", + "sha256": "5e6bb2e7e1628967407be673f70ffefe4744711bc856cb12d4fba041203ae22f", "localArea": "lib/model-catalog.ts", "reason": "Model catalog defaults and capabilities parity" }, { "path": "codex-rs/core/src/auth.rs", - "sha256": "ae9fc33ffe109798f6a4933371f32b789224d53c40f62ce20e3ab776ca8add0e", + "sha256": "4778717d5c53fc65db59e88ae45ba40d1d64478cd3e4fd5bfa2781028916bcd0", "localArea": "lib/codex-native/oauth-*.ts", "reason": "OAuth flow constants and auth semantics parity" }, { "path": "codex-rs/core/src/client.rs", - "sha256": "32e14e364a6607ff1e057cae16b59fd8f6d48cb3a5ed4932e0527e5932285298", + "sha256": "928da12079d3c0aa3200212ea33be6f950230bab38326eb15f7cc290499d03d1", "localArea": "lib/codex-native/chat-hooks.ts", "reason": "Request header/user-agent and endpoint behavior parity" }, { "path": "codex-rs/core/src/codex.rs", - "sha256": "da8084e81e46be18e3895fdc5e9470412ddc0b464c837c4ebcbef688101b1ca2", + "sha256": "00b1bf975735d776fbd5b684e8271108cd78df38130add972ae7d50f3901ca62", "localArea": "lib/codex-native/openai-loader-fetch.ts", "reason": "Core Codex request/runtime behavior parity" }, { "path": "codex-rs/core/src/compact.rs", - "sha256": "6d2cc48d6e0bda2a1d552741aee365d91a8e8a321d0990d279076de03335d3e8", + "sha256": "bb4d0787c7a841b4c99e0deed9256dd1f92e7de5db0b383e8fa14b4a91c92dc1", "localArea": "lib/codex-native.ts", "reason": "Compaction prompt and handoff semantics parity" } diff --git a/lib/codex-native/acquire-auth.ts b/lib/codex-native/acquire-auth.ts index 10445a7..65c00f1 100644 --- a/lib/codex-native/acquire-auth.ts +++ b/lib/codex-native/acquire-auth.ts @@ -20,6 +20,9 @@ function isOAuthTokenRefreshError(value: unknown): value is OAuthTokenRefreshErr const TERMINAL_REFRESH_ERROR_CODES = new Set([ "invalid_grant", "invalid_refresh_token", + "refresh_token_expired", + "refresh_token_reused", + "refresh_token_invalidated", "refresh_token_revoked", "token_revoked" ]) @@ -33,12 +36,18 @@ function isTerminalRefreshCredentialError(error: unknown): boolean { } if (error instanceof Error) { - const message = error.message.trim().toLowerCase() + const oauthMessage = + "oauthMessage" in error && typeof error.oauthMessage === "string" ? error.oauthMessage.trim().toLowerCase() : "" + const message = `${error.message.trim().toLowerCase()} ${oauthMessage}`.trim() if (message.includes("invalid_grant")) return true - if (message.includes("refresh token") && (message.includes("invalid") || message.includes("expired"))) { - return true - } - if (message.includes("refresh token") && message.includes("revoked")) { + if ( + message.includes("refresh token") && + (message.includes("invalid") || + message.includes("expired") || + message.includes("reused") || + message.includes("revoked") || + message.includes("invalidated")) + ) { return true } } diff --git a/lib/codex-native/client-identity.ts b/lib/codex-native/client-identity.ts index 77d9b5d..6a12319 100644 --- a/lib/codex-native/client-identity.ts +++ b/lib/codex-native/client-identity.ts @@ -13,7 +13,7 @@ import type { CodexOriginator } from "./originator.js" import { URL } from "node:url" const DEFAULT_PLUGIN_VERSION = "0.1.0" -const DEFAULT_CODEX_CLIENT_VERSION = "0.111.0" +const DEFAULT_CODEX_CLIENT_VERSION = "0.116.0" const CODEX_CLIENT_VERSION_CACHE_FILE = path.join(defaultOpencodeCachePath(), "codex-client-version.json") const CODEX_CLIENT_VERSION_TTL_MS = 60 * 60 * 1000 const CODEX_GITHUB_RELEASES_API = "https://api.github.com/repos/openai/codex/releases/latest" diff --git a/lib/codex-native/oauth-utils.ts b/lib/codex-native/oauth-utils.ts index d53c474..5f14ca2 100644 --- a/lib/codex-native/oauth-utils.ts +++ b/lib/codex-native/oauth-utils.ts @@ -107,6 +107,39 @@ export type TokenResponse = { export type OAuthTokenRefreshError = Error & { status?: number oauthCode?: string + oauthMessage?: string +} + +function parseOAuthErrorPayload(raw: string): { code?: string; message?: string } { + if (!raw) return {} + try { + const payload = JSON.parse(raw) as Record + const topLevelError = payload.error + if (typeof topLevelError === "string") { + return { + code: topLevelError, + message: typeof payload.error_description === "string" ? payload.error_description : undefined + } + } + + if (topLevelError && typeof topLevelError === "object" && !Array.isArray(topLevelError)) { + const nested = topLevelError as Record + return { + code: typeof nested.code === "string" ? nested.code : undefined, + message: + typeof nested.message === "string" + ? nested.message + : typeof payload.error_description === "string" + ? payload.error_description + : undefined + } + } + } catch (error) { + if (!(error instanceof SyntaxError)) { + // Best effort parse only. + } + } + return {} } export async function fetchWithTimeout( @@ -199,24 +232,15 @@ export async function refreshAccessToken(refreshToken: string): Promise - if (typeof payload.error === "string") oauthCode = payload.error - } - } catch (error) { - if (!(error instanceof SyntaxError)) { - // Best effort parse only. - } - // Best effort parse only. - } + const raw = await response.text() + const parsedError = parseOAuthErrorPayload(raw) + const oauthCode = parsedError.code const detail = oauthCode ? `${oauthCode}` : `status ${response.status}` const error = new Error(`Token refresh failed (${detail})`) as OAuthTokenRefreshError error.status = response.status error.oauthCode = oauthCode + error.oauthMessage = parsedError.message throw error } return (await response.json()) as TokenResponse diff --git a/lib/codex-native/openai-loader-fetch.ts b/lib/codex-native/openai-loader-fetch.ts index 45124d8..b20ab8c 100644 --- a/lib/codex-native/openai-loader-fetch.ts +++ b/lib/codex-native/openai-loader-fetch.ts @@ -80,7 +80,7 @@ export function createOpenAIFetchHandler(input: CreateOpenAIFetchHandlerInput) { const internalSelectedModelHeader = input.internalSelectedModelHeader ?? "x-opencode-selected-model-slug" const internalCollaborationAgentHeader = input.internalCollaborationAgentHeader ?? "x-opencode-collaboration-agent-kind" - const trustedSubagentValues = new Set(["review", "compact", "collab_spawn"]) + const trustedSubagentValues = new Set(["review", "compact", "memory_consolidation", "collab_spawn"]) const quotaTrackerByIdentity = new Map() const quotaRefreshAtByIdentity = new Map() const catalogSyncByScope = new Map() diff --git a/lib/codex-native/reasoning-summary.ts b/lib/codex-native/reasoning-summary.ts index b089964..490e7a4 100644 --- a/lib/codex-native/reasoning-summary.ts +++ b/lib/codex-native/reasoning-summary.ts @@ -42,7 +42,7 @@ export function resolveReasoningSummaryValue(input: { configuredValue?: unknown configuredSource?: string supportsReasoningSummaries?: boolean - defaultReasoningSummaryFormat?: string + defaultReasoningSummary?: string defaultReasoningSummarySource: string model?: string }): { value?: ReasoningSummaryValue; diagnostic?: ReasoningSummaryValidationDiagnostic } { @@ -88,7 +88,7 @@ export function resolveReasoningSummaryValue(input: { return {} } - const defaultValue = inspectReasoningSummaryValue(input.defaultReasoningSummaryFormat) + const defaultValue = inspectReasoningSummaryValue(input.defaultReasoningSummary) if (defaultValue.state === "invalid" && defaultValue.raw) { return { diagnostic: { diff --git a/lib/codex-native/request-transform-model.ts b/lib/codex-native/request-transform-model.ts index 31fbb86..c440f6e 100644 --- a/lib/codex-native/request-transform-model.ts +++ b/lib/codex-native/request-transform-model.ts @@ -64,6 +64,7 @@ type ChatParamsOutput = { type ModelRuntimeDefaults = { applyPatchToolType?: string defaultReasoningEffort?: string + defaultReasoningSummary?: string supportsReasoningSummaries?: boolean reasoningSummaryFormat?: string supportsParallelToolCalls?: boolean @@ -77,6 +78,7 @@ function readModelRuntimeDefaults(options: Record): ModelRuntim return { applyPatchToolType: asString(raw.applyPatchToolType), defaultReasoningEffort: asString(raw.defaultReasoningEffort), + defaultReasoningSummary: asString(raw.defaultReasoningSummary), supportsReasoningSummaries: typeof raw.supportsReasoningSummaries === "boolean" ? raw.supportsReasoningSummaries : undefined, reasoningSummaryFormat: asString(raw.reasoningSummaryFormat), @@ -531,6 +533,7 @@ export function applyResolvedCodexRuntimeDefaults(input: { defaults?: { applyPatchToolType?: string defaultReasoningEffort?: string + defaultReasoningSummary?: string supportsReasoningSummaries?: boolean reasoningSummaryFormat?: string supportsParallelToolCalls?: boolean @@ -582,8 +585,8 @@ export function applyResolvedCodexRuntimeDefaults(input: { configuredValue: input.resolvedBehavior.reasoningSummary, configuredSource: "config.reasoningSummary", supportsReasoningSummaries: defaults.supportsReasoningSummaries, - defaultReasoningSummaryFormat: defaults.reasoningSummaryFormat, - defaultReasoningSummarySource: "codexRuntimeDefaults.reasoningSummaryFormat", + defaultReasoningSummary: defaults.defaultReasoningSummary, + defaultReasoningSummarySource: "codexRuntimeDefaults.defaultReasoningSummary", model: input.modelId }) if (reasoningSummary.value) { diff --git a/lib/codex-native/request-transform-payload.ts b/lib/codex-native/request-transform-payload.ts index 27a7182..c9bc316 100644 --- a/lib/codex-native/request-transform-payload.ts +++ b/lib/codex-native/request-transform-payload.ts @@ -459,9 +459,10 @@ function resolveDefaultReasoningSummary( defaults: ReturnType | undefined ): string | undefined { if (defaults?.supportsReasoningSummaries !== true) return undefined - const format = defaults.reasoningSummaryFormat?.trim().toLowerCase() - if (format === "none") return undefined - return defaults.reasoningSummaryFormat ?? "auto" + const summary = defaults.defaultReasoningSummary?.trim().toLowerCase() + if (!summary) return "auto" + if (summary === "none") return undefined + return defaults.defaultReasoningSummary } function replaceCatalogInstructionPrefix( @@ -591,8 +592,8 @@ function validateReasoningSummaryPayload(input: { configuredValue: modelReasoningSummaryOverride ?? customModelReasoningSummaryOverride ?? globalReasoningSummary, configuredSource: "config.reasoningSummary", supportsReasoningSummaries: defaults?.supportsReasoningSummaries, - defaultReasoningSummaryFormat: defaults?.reasoningSummaryFormat, - defaultReasoningSummarySource: "codexRuntimeDefaults.reasoningSummaryFormat", + defaultReasoningSummary: defaults?.defaultReasoningSummary, + defaultReasoningSummarySource: "codexRuntimeDefaults.defaultReasoningSummary", model: modelSlug ?? selectedModelSlug }).diagnostic } diff --git a/lib/model-catalog/provider.ts b/lib/model-catalog/provider.ts index 0bbc7f3..e2d1450 100644 --- a/lib/model-catalog/provider.ts +++ b/lib/model-catalog/provider.ts @@ -410,6 +410,11 @@ export function getRuntimeDefaultsForModel(model: CodexModelInfo | undefined): C out.defaultReasoningEffort = defaultReasoningEffort } + if (typeof model.default_reasoning_summary === "string") { + const next = model.default_reasoning_summary.trim() + if (next) out.defaultReasoningSummary = next + } + const supportedReasoningEfforts = Array.from( new Set( (model.supported_reasoning_levels ?? []) diff --git a/lib/model-catalog/shared.ts b/lib/model-catalog/shared.ts index b41421d..9987bae 100644 --- a/lib/model-catalog/shared.ts +++ b/lib/model-catalog/shared.ts @@ -58,6 +58,7 @@ export type CodexModelInfo = { supports_parallel_tool_calls?: boolean | null support_verbosity?: boolean | null default_verbosity?: string | null + default_reasoning_summary?: string | null } type CodexModelsResponse = { @@ -73,6 +74,7 @@ export type CodexModelsCache = { export type CodexModelRuntimeDefaults = { applyPatchToolType?: string defaultReasoningEffort?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh" + defaultReasoningSummary?: string supportedReasoningEfforts?: Array<"none" | "minimal" | "low" | "medium" | "high" | "xhigh"> supportsReasoningSummaries?: boolean reasoningSummaryFormat?: string @@ -121,7 +123,7 @@ export type ApplyCodexCatalogInput = { export const CODEX_MODELS_ENDPOINT = "https://chatgpt.com/backend-api/codex/models" export const CODEX_GITHUB_MODELS_URL_PREFIX = "https://raw.githubusercontent.com/openai/codex" -export const DEFAULT_CLIENT_VERSION = "0.111.0" +export const DEFAULT_CLIENT_VERSION = "0.116.0" export const CACHE_TTL_MS = 15 * 60 * 1000 export const FETCH_TIMEOUT_MS = 5000 export const EFFORT_SUFFIX_REGEX = /-(none|minimal|low|medium|high|xhigh)$/i @@ -255,7 +257,9 @@ export function parseCatalogResponse(payload: unknown): CodexModelInfo[] { supports_parallel_tool_calls: typeof item.supports_parallel_tool_calls === "boolean" ? item.supports_parallel_tool_calls : null, support_verbosity: typeof item.support_verbosity === "boolean" ? item.support_verbosity : null, - default_verbosity: typeof item.default_verbosity === "string" ? item.default_verbosity : null + default_verbosity: typeof item.default_verbosity === "string" ? item.default_verbosity : null, + default_reasoning_summary: + typeof item.default_reasoning_summary === "string" ? item.default_reasoning_summary : null }) } diff --git a/lib/proactive-refresh.ts b/lib/proactive-refresh.ts index fe08bb4..85be671 100644 --- a/lib/proactive-refresh.ts +++ b/lib/proactive-refresh.ts @@ -5,6 +5,9 @@ const PROACTIVE_REFRESH_FAILURE_COOLDOWN_MS = 30_000 const TERMINAL_REFRESH_ERROR_CODES = new Set([ "invalid_grant", "invalid_refresh_token", + "refresh_token_expired", + "refresh_token_reused", + "refresh_token_invalidated", "refresh_token_revoked", "token_revoked" ]) @@ -22,9 +25,18 @@ function isInvalidGrantError(error: unknown): boolean { } if (error instanceof Error) { - const message = error.message.trim().toLowerCase() + const oauthMessage = + "oauthMessage" in error && typeof error.oauthMessage === "string" ? error.oauthMessage.trim().toLowerCase() : "" + const message = `${error.message.trim().toLowerCase()} ${oauthMessage}`.trim() if (message.includes("invalid_grant")) return true - if (message.includes("refresh token") && (message.includes("invalid") || message.includes("revoked"))) { + if ( + message.includes("refresh token") && + (message.includes("invalid") || + message.includes("expired") || + message.includes("reused") || + message.includes("revoked") || + message.includes("invalidated")) + ) { return true } } diff --git a/package-lock.json b/package-lock.json index 9791a25..9c665d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,8 +17,8 @@ }, "devDependencies": { "@biomejs/biome": "^2.0.6", - "@opencode-ai/plugin": "^1.2.18", - "@opencode-ai/sdk": "^1.2.18", + "@opencode-ai/plugin": "^1.3.0", + "@opencode-ai/sdk": "^1.3.0", "@types/node": "^20.17.24", "@types/proper-lockfile": "^4.1.2", "@vitest/coverage-v8": "^3.2.4", @@ -29,7 +29,7 @@ "node": ">=22 <23" }, "peerDependencies": { - "@opencode-ai/plugin": "^1.2.18" + "@opencode-ai/plugin": "^1.3.0" } }, "node_modules/@ampproject/remapping": { @@ -779,20 +779,20 @@ } }, "node_modules/@opencode-ai/plugin": { - "version": "1.2.18", - "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.2.18.tgz", - "integrity": "sha512-5GwHnWPWLuKKenOpJHGxF2L2NLe1q35jYlyC777EIUb1bQJdGyRRWjYloI3Iq0/7PDR1t2xf5dADTdVVOT8wmQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.3.0.tgz", + "integrity": "sha512-mR1Kdcpr3Iv+KS7cL2DRFB6QAcSoR6/DojmwuxYF/pMCahMtaCLiqZGQjoSNl12+gQ6RsIJJyUh/jX3JVlOx8A==", "dev": true, "license": "MIT", "dependencies": { - "@opencode-ai/sdk": "1.2.18", + "@opencode-ai/sdk": "1.3.0", "zod": "4.1.8" } }, "node_modules/@opencode-ai/sdk": { - "version": "1.2.18", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.2.18.tgz", - "integrity": "sha512-MJhnCtbZDxi6QOF2hDOCVzqBX0PjXWhRU/DNvIWGAHrPzUQ836mVu0QG+KIWxIUxAcOFcQmYfABr2YiawN8a0Q==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.3.0.tgz", + "integrity": "sha512-5WyYEpcV6Zk9otXOMIrvZRbJm1yxt/c8EXSBn1p6Sw1yagz8HRljkoUTJFxzD0x2+/6vAZItr3OrXDZfE+oA2g==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index 4942ce1..dbb3d64 100644 --- a/package.json +++ b/package.json @@ -63,8 +63,8 @@ "release:minor": "npm run release -- minor", "release:major": "npm run release -- major", "lint": "biome lint index.ts lib test bin scripts --error-on-warnings", - "format": "biome format --write .", - "format:check": "biome format ." + "format": "biome format --write index.ts lib test bin scripts docs package.json biome.json README.md CHANGELOG.md CONTRIBUTING.md SECURITY.md LICENSE schemas tsconfig.json tsconfig.test.json vitest.config.ts", + "format:check": "biome format index.ts lib test bin scripts docs package.json biome.json README.md CHANGELOG.md CONTRIBUTING.md SECURITY.md LICENSE schemas tsconfig.json tsconfig.test.json vitest.config.ts --diagnostic-level=error" }, "files": [ "dist/bin/", @@ -81,12 +81,12 @@ "node": ">=22 <23" }, "peerDependencies": { - "@opencode-ai/plugin": "^1.2.18" + "@opencode-ai/plugin": "^1.3.0" }, "devDependencies": { "@biomejs/biome": "^2.0.6", - "@opencode-ai/plugin": "^1.2.18", - "@opencode-ai/sdk": "^1.2.18", + "@opencode-ai/plugin": "^1.3.0", + "@opencode-ai/sdk": "^1.3.0", "@types/node": "^20.17.24", "@types/proper-lockfile": "^4.1.2", "@vitest/coverage-v8": "^3.2.4", diff --git a/test/codex-native-client-version.test.ts b/test/codex-native-client-version.test.ts index a0ff81a..2e265c0 100644 --- a/test/codex-native-client-version.test.ts +++ b/test/codex-native-client-version.test.ts @@ -19,10 +19,10 @@ describe("codex client version resolution", () => { expect(__testOnly.resolveCodexClientVersion(cacheFile)).toBe("0.98.0") }) - it("falls back to 0.111.0 when cache file is missing", async () => { + it("falls back to 0.116.0 when cache file is missing", async () => { const dir = await makeTmpDir() const cacheFile = path.join(dir, "missing.json") - expect(__testOnly.resolveCodexClientVersion(cacheFile)).toBe("0.111.0") + expect(__testOnly.resolveCodexClientVersion(cacheFile)).toBe("0.116.0") }) it("refreshes stale cache from GitHub release tag", async () => { diff --git a/test/codex-native-fatal-response.test.ts b/test/codex-native-fatal-response.test.ts index 3815fd0..4caa9da 100644 --- a/test/codex-native-fatal-response.test.ts +++ b/test/codex-native-fatal-response.test.ts @@ -331,6 +331,58 @@ describe("codex-native fatal responses", () => { expect(body.error.message).toContain("reauthenticate") }) + it("treats nested Codex refresh_token_reused failures as terminal refresh guidance", async () => { + stubGlobalForTest( + "fetch", + vi.fn(async (url: string | URL | Request) => { + const requestUrl = url.toString() + if (requestUrl.includes("/oauth/token")) { + return new Response( + JSON.stringify({ + error: { + code: "refresh_token_reused", + message: "Your refresh token has already been used to generate a new access token." + } + }), + { + status: 401, + headers: { "content-type": "application/json; charset=utf-8" } + } + ) + } + return new Response("ok", { status: 200 }) + }) + ) + + const { loaded } = await loadPluginForAuth({ + openai: { + type: "oauth", + activeIdentityKey: "acc|user@example.com|plus", + accounts: [ + { + identityKey: "acc|user@example.com|plus", + accountId: "acc", + email: "user@example.com", + plan: "plus", + enabled: true, + refresh: "rt", + expires: 0 + } + ] + } + }) + + const response = await loaded.fetch?.("https://api.openai.com/v1/responses", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.2-codex", input: "hi" }) + }) + + expect(response?.status).toBe(401) + const body = await response?.json() + expect(body.error.type).toBe("refresh_invalid_grant") + expect(body.error.message).toContain("reauthenticate") + }) + it("fails over to another enabled account when one refresh token is invalid_grant", async () => { const fetchImpl = vi.fn(async (url: string | URL | Request, _init?: RequestInit) => { const requestUrl = url.toString() diff --git a/test/codex-native-oauth-auth-methods.test.ts b/test/codex-native-oauth-auth-methods.test.ts index 4568489..8defe3b 100644 --- a/test/codex-native-oauth-auth-methods.test.ts +++ b/test/codex-native-oauth-auth-methods.test.ts @@ -88,6 +88,47 @@ describe("createBrowserOAuthAuthorize", () => { stdout.isTTY = previousOut } }) + + it("defaults empty authorize inputs to the interactive auth menu in tty mode", async () => { + const scheduleOAuthServerStop = vi.fn() + const persistOAuthTokens = vi.fn() + const openAuthUrl = vi.fn() + const runInteractiveAuthMenu = vi.fn<(options: { allowExit: boolean }) => Promise<"add" | "exit">>( + async () => "exit" + ) + + const stdin = process.stdin as NodeJS.ReadStream & { isTTY?: boolean } + const stdout = process.stdout as NodeJS.WriteStream & { isTTY?: boolean } + const previousIn = stdin.isTTY + const previousOut = stdout.isTTY + stdin.isTTY = true + stdout.isTTY = true + + try { + const authorize = createBrowserOAuthAuthorize({ + authMode: "native", + spoofMode: "native", + runInteractiveAuthMenu, + startOAuthServer: vi.fn(async () => ({ redirectUri: "http://localhost:1455/auth/callback" })), + waitForOAuthCallback: vi.fn(async () => { + throw new Error("callback failed") + }), + scheduleOAuthServerStop, + persistOAuthTokens, + openAuthUrl, + shutdownGraceMs: 1_000, + shutdownErrorGraceMs: 5_000 + }) + + const payload = await authorize({}) + expect(payload.url).toBe("") + expect(payload.instructions).toBe("Login cancelled.") + expect(runInteractiveAuthMenu).toHaveBeenCalledTimes(1) + } finally { + stdin.isTTY = previousIn + stdout.isTTY = previousOut + } + }) }) describe("createHeadlessOAuthAuthorize", () => { diff --git a/test/codex-native-oauth-utils.test.ts b/test/codex-native-oauth-utils.test.ts new file mode 100644 index 0000000..2c0d7ab --- /dev/null +++ b/test/codex-native-oauth-utils.test.ts @@ -0,0 +1,88 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import { refreshAccessToken } from "../lib/codex-native/oauth-utils" +import { resetStubbedGlobals, stubGlobalForTest } from "./helpers/mock-policy" + +describe("codex-native oauth utils", () => { + afterEach(() => { + resetStubbedGlobals() + }) + + it("refreshes access tokens from the oauth token endpoint", async () => { + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify({ access_token: "access_next", refresh_token: "refresh_next", expires_in: 3600 }), { + status: 200, + headers: { "Content-Type": "application/json" } + }) + ) + stubGlobalForTest("fetch", fetchMock) + + const tokens = await refreshAccessToken("refresh_current") + + expect(tokens).toEqual({ + access_token: "access_next", + refresh_token: "refresh_next", + expires_in: 3600 + }) + expect(fetchMock).toHaveBeenCalledTimes(1) + const call = fetchMock.mock.calls.at(0) + expect(call).toBeDefined() + if (!call) { + throw new Error("expected fetch call") + } + const [input, init] = call as unknown as [RequestInfo | URL, RequestInit | undefined] + expect(typeof input === "string" ? input : input.toString()).toBe("https://auth.openai.com/oauth/token") + expect(init?.method).toBe("POST") + expect(init?.headers).toEqual({ "Content-Type": "application/x-www-form-urlencoded" }) + expect(String(init?.body)).toContain("grant_type=refresh_token") + expect(String(init?.body)).toContain("refresh_token=refresh_current") + }) + + it("surfaces top-level oauth error descriptions on refresh failures", async () => { + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify({ error: "invalid_grant", error_description: "Refresh token expired" }), { + status: 400, + headers: { "Content-Type": "application/json" } + }) + ) + stubGlobalForTest("fetch", fetchMock) + + await expect(refreshAccessToken("refresh_expired")).rejects.toMatchObject({ + message: "Token refresh failed (invalid_grant)", + status: 400, + oauthCode: "invalid_grant", + oauthMessage: "Refresh token expired" + }) + }) + + it("surfaces nested oauth error payloads on refresh failures", async () => { + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify({ error: { code: "refresh_token_reused", message: "Refresh token reused" } }), { + status: 401, + headers: { "Content-Type": "application/json" } + }) + ) + stubGlobalForTest("fetch", fetchMock) + + await expect(refreshAccessToken("refresh_reused")).rejects.toMatchObject({ + message: "Token refresh failed (refresh_token_reused)", + status: 401, + oauthCode: "refresh_token_reused", + oauthMessage: "Refresh token reused" + }) + }) + + it("falls back to status details when the oauth error body is not json", async () => { + const fetchMock = vi.fn(async () => new Response("not json", { status: 502 })) + stubGlobalForTest("fetch", fetchMock) + + await expect(refreshAccessToken("refresh_unknown")).rejects.toMatchObject({ + message: "Token refresh failed (status 502)", + status: 502, + oauthCode: undefined, + oauthMessage: undefined + }) + }) +}) diff --git a/test/codex-native-request-transform.test.ts b/test/codex-native-request-transform.test.ts index b6f6cfd..b9b98e8 100644 --- a/test/codex-native-request-transform.test.ts +++ b/test/codex-native-request-transform.test.ts @@ -587,7 +587,7 @@ describe("reasoning summary validation diagnostics", () => { slug: "gpt-5.3-codex", default_reasoning_level: "high", supports_reasoning_summaries: true, - reasoning_summary_format: "experimental" + default_reasoning_summary: "experimental" } ] }) @@ -595,7 +595,7 @@ describe("reasoning summary validation diagnostics", () => { expect(transformed.reasoningSummaryValidation).toEqual({ actual: "experimental", model: "gpt-5.3-codex", - source: "codexRuntimeDefaults.reasoningSummaryFormat", + source: "codexRuntimeDefaults.defaultReasoningSummary", sourceType: "catalog_default" }) }) @@ -625,7 +625,7 @@ describe("reasoning summary validation diagnostics", () => { slug: "gpt-5.3-codex", default_reasoning_level: "high", supports_reasoning_summaries: true, - reasoning_summary_format: "experimental" + default_reasoning_summary: "experimental" } ], customModels: { @@ -645,6 +645,7 @@ describe("catalog-scoped payload cleanup", () => { { slug: "gpt-5.3-codex", default_reasoning_level: "high", + default_reasoning_summary: "auto", supports_reasoning_summaries: true, reasoning_summary_format: "auto", default_verbosity: "medium", @@ -823,6 +824,7 @@ describe("catalog-scoped payload cleanup", () => { { slug: "gpt-5.3-codex", default_reasoning_level: "low", + default_reasoning_summary: "concise", supports_reasoning_summaries: true, reasoning_summary_format: "concise", default_verbosity: "low", @@ -882,6 +884,7 @@ describe("catalog-scoped payload cleanup", () => { { slug: "gpt-5.3-codex", default_reasoning_level: "low", + default_reasoning_summary: "concise", supports_reasoning_summaries: true, reasoning_summary_format: "concise", default_verbosity: "low", @@ -945,6 +948,7 @@ describe("catalog-scoped payload cleanup", () => { { slug: "gpt-5.3-codex", default_reasoning_level: "low", + default_reasoning_summary: "concise", supports_reasoning_summaries: true, reasoning_summary_format: "concise", default_verbosity: "low", diff --git a/test/codex-native-spoof-mode.test.ts b/test/codex-native-spoof-mode.test.ts index cf46238..b1877f8 100644 --- a/test/codex-native-spoof-mode.test.ts +++ b/test/codex-native-spoof-mode.test.ts @@ -62,7 +62,7 @@ describe("codex-native spoof + params hooks", () => { expect(output.options.include).toEqual(["web_search_call.action.sources", "reasoning.encrypted_content"]) }) - it("drops invalid model reasoning summary format defaults from chat params", async () => { + it("falls back to auto when only the reasoning summary format is non-user-facing", async () => { const hooks = await CodexAuthPlugin({} as never) const chatParams = hooks["chat.params"] expect(chatParams).toBeTypeOf("function") @@ -93,10 +93,10 @@ describe("codex-native spoof + params hooks", () => { } await chatParams?.(input, output) - expect(output.options.reasoningSummary).toBeUndefined() + expect(output.options.reasoningSummary).toBe("auto") }) - it("treats model reasoning summary format none as disabled", async () => { + it("treats model default reasoning summary none as disabled", async () => { const hooks = await CodexAuthPlugin({} as never) const chatParams = hooks["chat.params"] expect(chatParams).toBeTypeOf("function") @@ -113,6 +113,7 @@ describe("codex-native spoof + params hooks", () => { codexRuntimeDefaults: { defaultReasoningEffort: "high", supportsReasoningSummaries: true, + defaultReasoningSummary: "none", reasoningSummaryFormat: "none" } } diff --git a/test/codex-native-user-agent.test.ts b/test/codex-native-user-agent.test.ts index 09797c0..cf731f9 100644 --- a/test/codex-native-user-agent.test.ts +++ b/test/codex-native-user-agent.test.ts @@ -1,3 +1,6 @@ +import os from "node:os" +import process from "node:process" + import { describe, expect, it, vi } from "vitest" import { __testOnly } from "../lib/codex-native" @@ -21,6 +24,12 @@ describe("codex-native user-agent parity", () => { expect(codexUa).toMatch(/^codex_cli_rs\//) }) + it("keeps opencode formatting when codex mode resolves the opencode originator", () => { + const ua = __testOnly.resolveRequestUserAgent("codex", "opencode") + expect(ua).toMatch(/^opencode\/\d+\.\d+\.\d+/) + expect(ua).not.toContain("codex_cli_rs") + }) + it("sanitizes non-ascii terminal metadata and keeps UA printable", async () => { vi.resetModules() const originalTermProgram = process.env.TERM_PROGRAM @@ -75,4 +84,84 @@ describe("codex-native user-agent parity", () => { } } }) + + it("falls back from tmux probing to the TERM_PROGRAM token when tmux metadata is unavailable", async () => { + vi.resetModules() + const saved = { + TERM_PROGRAM: process.env.TERM_PROGRAM, + TERM_PROGRAM_VERSION: process.env.TERM_PROGRAM_VERSION, + TMUX: process.env.TMUX, + TMUX_PANE: process.env.TMUX_PANE + } + try { + process.env.TERM_PROGRAM = "tmux" + process.env.TERM_PROGRAM_VERSION = "3.4" + process.env.TMUX = "/tmp/tmux-session" + delete process.env.TMUX_PANE + const identity = await import("../lib/codex-native/client-identity") + const ua = identity.buildCodexUserAgent("codex_cli_rs") + expect(ua.endsWith(" tmux/3.4")).toBe(true) + } finally { + if (saved.TERM_PROGRAM === undefined) delete process.env.TERM_PROGRAM + else process.env.TERM_PROGRAM = saved.TERM_PROGRAM + if (saved.TERM_PROGRAM_VERSION === undefined) delete process.env.TERM_PROGRAM_VERSION + else process.env.TERM_PROGRAM_VERSION = saved.TERM_PROGRAM_VERSION + if (saved.TMUX === undefined) delete process.env.TMUX + else process.env.TMUX = saved.TMUX + if (saved.TMUX_PANE === undefined) delete process.env.TMUX_PANE + else process.env.TMUX_PANE = saved.TMUX_PANE + } + }) + + it("formats Windows platform signatures with normalized x64 architecture", async () => { + vi.resetModules() + const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform") + const originalTermProgram = process.env.TERM_PROGRAM + const originalTermProgramVersion = process.env.TERM_PROGRAM_VERSION + const archSpy = vi.spyOn(os, "arch").mockReturnValue("x64") + const releaseSpy = vi.spyOn(os, "release").mockReturnValue("10.0.22631") + try { + Object.defineProperty(process, "platform", { configurable: true, value: "win32" }) + process.env.TERM_PROGRAM = "Windows Terminal" + process.env.TERM_PROGRAM_VERSION = "1.20" + const identity = await import("../lib/codex-native/client-identity") + const ua = identity.buildCodexUserAgent("codex_exec") + expect(ua).toContain("(Windows 10.0.22631; x86_64)") + expect(ua.endsWith(" Windows_Terminal/1.20")).toBe(true) + } finally { + archSpy.mockRestore() + releaseSpy.mockRestore() + if (originalPlatform) { + Object.defineProperty(process, "platform", originalPlatform) + } + if (originalTermProgram === undefined) delete process.env.TERM_PROGRAM + else process.env.TERM_PROGRAM = originalTermProgram + if (originalTermProgramVersion === undefined) delete process.env.TERM_PROGRAM_VERSION + else process.env.TERM_PROGRAM_VERSION = originalTermProgramVersion + } + }) + + it("falls back unknown architecture and platform labels when runtime values are empty", async () => { + vi.resetModules() + const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform") + const originalTerm = process.env.TERM + const archSpy = vi.spyOn(os, "arch").mockReturnValue("") + const releaseSpy = vi.spyOn(os, "release").mockReturnValue("5.11") + try { + Object.defineProperty(process, "platform", { configurable: true, value: "sunos" }) + process.env.TERM = "vt100" + const identity = await import("../lib/codex-native/client-identity") + const ua = identity.buildCodexUserAgent("codex_exec") + expect(ua).toContain("(sunos 5.11; unknown)") + expect(ua.endsWith(" vt100")).toBe(true) + } finally { + archSpy.mockRestore() + releaseSpy.mockRestore() + if (originalPlatform) { + Object.defineProperty(process, "platform", originalPlatform) + } + if (originalTerm === undefined) delete process.env.TERM + else process.env.TERM = originalTerm + } + }) }) diff --git a/test/mode-smoke.test.ts b/test/mode-smoke.test.ts index 5a4a901..34d517c 100644 --- a/test/mode-smoke.test.ts +++ b/test/mode-smoke.test.ts @@ -47,6 +47,7 @@ describe("mode smoke: native vs codex", () => { codexRuntimeDefaults: { defaultReasoningEffort: "high", supportsReasoningSummaries: true, + defaultReasoningSummary: "auto", reasoningSummaryFormat: "experimental", defaultVerbosity: "medium" } @@ -96,8 +97,8 @@ describe("mode smoke: native vs codex", () => { expect(codexWithHost.options.instructions).toBe("Catalog Codex Instructions") expect(nativeNoHost.options.instructions).toBe("Catalog Codex Instructions") expect(codexNoHost.options.instructions).toBe("Catalog Codex Instructions") - expect(nativeNoHost.options.reasoningSummary).toBeUndefined() - expect(codexNoHost.options.reasoningSummary).toBeUndefined() + expect(nativeNoHost.options.reasoningSummary).toBe("auto") + expect(codexNoHost.options.reasoningSummary).toBe("auto") expect(nativeNoHost.options.textVerbosity).toBe("medium") expect(codexNoHost.options.textVerbosity).toBe("medium") expect(nativeNoHost.options.serviceTier).toBe("priority") diff --git a/test/model-catalog.fetch-cache.test.ts b/test/model-catalog.fetch-cache.test.ts index 22a7c25..7833436 100644 --- a/test/model-catalog.fetch-cache.test.ts +++ b/test/model-catalog.fetch-cache.test.ts @@ -16,11 +16,11 @@ describe("model catalog fetch and primary cache", () => { const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { const endpoint = typeof url === "string" ? url : url instanceof URL ? url.toString() : new URL(url.url).toString() if (endpoint.includes("/backend-api/codex/models")) { - expect(endpoint).toContain("client_version=0.111.0") + expect(endpoint).toContain("client_version=0.116.0") const headers = init?.headers as Record expect(headers.authorization).toBe("Bearer at") expect(headers["chatgpt-account-id"]).toBe("acc_123") - expect(headers.version).toBe("0.111.0") + expect(headers.version).toBe("0.116.0") return new Response( JSON.stringify({ @@ -30,7 +30,7 @@ describe("model catalog fetch and primary cache", () => { ) } - expect(endpoint).toBe("https://raw.githubusercontent.com/openai/codex/rust-v0.111.0/codex-rs/core/models.json") + expect(endpoint).toBe("https://raw.githubusercontent.com/openai/codex/rust-v0.116.0/codex-rs/core/models.json") return new Response( JSON.stringify({ models: [ diff --git a/test/model-catalog.provider-models.test.ts b/test/model-catalog.provider-models.test.ts index e89e6b2..bae2aee 100644 --- a/test/model-catalog.provider-models.test.ts +++ b/test/model-catalog.provider-models.test.ts @@ -77,6 +77,7 @@ describe("model catalog provider model mapping", () => { apply_patch_tool_type: null, supported_reasoning_levels: null, default_reasoning_level: null, + default_reasoning_summary: null, supports_reasoning_summaries: null, reasoning_summary_format: null, supports_parallel_tool_calls: true, @@ -100,6 +101,7 @@ describe("model catalog provider model mapping", () => { input_modalities: ["text", "image"] as const, apply_patch_tool_type: "apply_patch", default_reasoning_level: "medium", + default_reasoning_summary: "auto", supported_reasoning_levels: [{ effort: "low" }, { effort: "medium" }, { effort: "high" }], supports_reasoning_summaries: true, reasoning_summary_format: "experimental", @@ -126,6 +128,7 @@ describe("model catalog provider model mapping", () => { expect(providerModels["gpt-5.4-codex"].codexRuntimeDefaults).toEqual({ applyPatchToolType: "apply_patch", defaultReasoningEffort: "medium", + defaultReasoningSummary: "auto", supportedReasoningEfforts: ["low", "medium", "high"], supportsReasoningSummaries: true, reasoningSummaryFormat: "experimental", diff --git a/test/models-gpt-5.3-codex.test.ts b/test/models-gpt-5.3-codex.test.ts index 8776e43..6dc6460 100644 --- a/test/models-gpt-5.3-codex.test.ts +++ b/test/models-gpt-5.3-codex.test.ts @@ -402,7 +402,7 @@ describe("codex-native model allowlist", () => { const instructions = accountId === secondAccountId ? "Instructions for account B" : "Instructions for account A" const defaultReasoningLevel = accountId === secondAccountId ? "low" : "high" - const reasoningSummaryFormat = accountId === secondAccountId ? "concise" : "auto" + const defaultReasoningSummary = accountId === secondAccountId ? "concise" : "auto" const defaultVerbosity = accountId === secondAccountId ? "low" : "medium" const supportsParallelToolCalls = accountId !== secondAccountId return new Response( @@ -413,8 +413,9 @@ describe("codex-native model allowlist", () => { context_window: 272000, input_modalities: ["text"], default_reasoning_level: defaultReasoningLevel, + default_reasoning_summary: defaultReasoningSummary, supports_reasoning_summaries: true, - reasoning_summary_format: reasoningSummaryFormat, + reasoning_summary_format: "experimental", default_verbosity: defaultVerbosity, supports_parallel_tool_calls: supportsParallelToolCalls, model_messages: { @@ -621,7 +622,7 @@ describe("codex-native model allowlist", () => { const instructions = accountId === secondAccountId ? "Instructions for account B" : "Instructions for account A" const defaultReasoningLevel = accountId === secondAccountId ? "low" : "high" - const reasoningSummaryFormat = accountId === secondAccountId ? "concise" : "auto" + const defaultReasoningSummary = accountId === secondAccountId ? "concise" : "auto" const defaultVerbosity = accountId === secondAccountId ? "low" : "medium" const supportsParallelToolCalls = accountId !== secondAccountId return new Response( @@ -632,8 +633,9 @@ describe("codex-native model allowlist", () => { context_window: 272000, input_modalities: ["text"], default_reasoning_level: defaultReasoningLevel, + default_reasoning_summary: defaultReasoningSummary, supports_reasoning_summaries: true, - reasoning_summary_format: reasoningSummaryFormat, + reasoning_summary_format: "experimental", default_verbosity: defaultVerbosity, supports_parallel_tool_calls: supportsParallelToolCalls, model_messages: { diff --git a/test/openai-loader-fetch.prompt-cache-key.core-behavior.test.ts b/test/openai-loader-fetch.prompt-cache-key.core-behavior.test.ts index 8b530d0..3b0ba4d 100644 --- a/test/openai-loader-fetch.prompt-cache-key.core-behavior.test.ts +++ b/test/openai-loader-fetch.prompt-cache-key.core-behavior.test.ts @@ -152,7 +152,7 @@ describe("openai loader fetch prompt cache key (core behavior)", () => { slug: "gpt-5.3-codex", default_reasoning_level: "high", supports_reasoning_summaries: true, - reasoning_summary_format: "experimental" + default_reasoning_summary: "experimental" } ], syncCatalogFromAuth: async () => undefined, @@ -178,10 +178,10 @@ describe("openai loader fetch prompt cache key (core behavior)", () => { await expect(response.json()).resolves.toEqual({ error: { message: - "Invalid reasoning summary setting source: selected model catalog default `codexRuntimeDefaults.reasoningSummaryFormat` for `gpt-5.3-codex` is `experimental`. Supported values are `auto`, `concise`, `detailed`, `none`.", + "Invalid reasoning summary setting source: selected model catalog default `codexRuntimeDefaults.defaultReasoningSummary` for `gpt-5.3-codex` is `experimental`. Supported values are `auto`, `concise`, `detailed`, `none`.", type: "invalid_reasoning_summary", param: "reasoning.summary", - source: "codexRuntimeDefaults.reasoningSummaryFormat", + source: "codexRuntimeDefaults.defaultReasoningSummary", hint: 'This source is internal, not a user config key. Disable summaries with `reasoningSummary: "none"` if you need a workaround.' } }) diff --git a/test/proactive-refresh.integration.test.ts b/test/proactive-refresh.integration.test.ts index b25d908..cc2bdb1 100644 --- a/test/proactive-refresh.integration.test.ts +++ b/test/proactive-refresh.integration.test.ts @@ -218,6 +218,102 @@ describe("proactive refresh", () => { expect(refresh).toHaveBeenCalledTimes(1) }) + it("disables account when proactive refresh returns refresh_token_reused", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-refresh-")) + const p = path.join(dir, "auth.json") + + await saveAuthStorage(p, (cur) => ({ + ...cur, + openai: { + type: "oauth", + strategy: "round_robin", + accounts: [ + { + identityKey: "reused", + enabled: true, + refresh: "rreused", + access: "oldReused", + expires: 0, + accountId: "7", + email: "reused@example.com", + plan: "plus" + } + ] + } + })) + + const refresh = vi.fn(async () => { + const error = new Error("Token refresh failed (refresh_token_reused)") + ;(error as Error & { oauthCode?: string }).oauthCode = "refresh_token_reused" + throw error + }) + + await runOneProactiveRefreshTick({ + authPath: p, + now: () => 1_000, + bufferMs: 10_000, + refresh + }) + + const stored = await loadAuthStorage(p) + const openai = stored.openai + if (!openai || !("accounts" in openai)) throw new Error("missing") + + const account = openai.accounts.find((a) => a.identityKey === "reused") + expect(account?.enabled).toBe(false) + expect(account?.refreshLeaseUntil).toBeUndefined() + expect(account?.cooldownUntil).toBeUndefined() + expect(refresh).toHaveBeenCalledTimes(1) + }) + + it("disables account when proactive refresh only reports terminal failure in oauthMessage", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-refresh-")) + const p = path.join(dir, "auth.json") + + await saveAuthStorage(p, (cur) => ({ + ...cur, + openai: { + type: "oauth", + strategy: "round_robin", + accounts: [ + { + identityKey: "invalidated", + enabled: true, + refresh: "rinvalidated", + access: "oldInvalidated", + expires: 0, + accountId: "8", + email: "invalidated@example.com", + plan: "plus" + } + ] + } + })) + + const refresh = vi.fn(async () => { + const error = new Error("Token refresh failed") + ;(error as Error & { oauthMessage?: string }).oauthMessage = "Refresh token invalidated" + throw error + }) + + await runOneProactiveRefreshTick({ + authPath: p, + now: () => 1_000, + bufferMs: 10_000, + refresh + }) + + const stored = await loadAuthStorage(p) + const openai = stored.openai + if (!openai || !("accounts" in openai)) throw new Error("missing") + + const account = openai.accounts.find((a) => a.identityKey === "invalidated") + expect(account?.enabled).toBe(false) + expect(account?.refreshLeaseUntil).toBeUndefined() + expect(account?.cooldownUntil).toBeUndefined() + expect(refresh).toHaveBeenCalledTimes(1) + }) + it("clears lease and applies cooldown for transient proactive refresh failures", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-refresh-")) const p = path.join(dir, "auth.json") diff --git a/test/reasoning-summary.test.ts b/test/reasoning-summary.test.ts index 4e30ad6..a49d71a 100644 --- a/test/reasoning-summary.test.ts +++ b/test/reasoning-summary.test.ts @@ -27,7 +27,7 @@ describe("reasoning summary helpers", () => { explicitValue: "experimental", explicitSource: "request.reasoning.summary", hasReasoning: true, - defaultReasoningSummarySource: "codexRuntimeDefaults.reasoningSummaryFormat" + defaultReasoningSummarySource: "codexRuntimeDefaults.defaultReasoningSummary" }) ).toEqual({ diagnostic: { @@ -43,7 +43,7 @@ describe("reasoning summary helpers", () => { hasReasoning: true, configuredValue: "invalid", configuredSource: "config.reasoningSummary", - defaultReasoningSummarySource: "codexRuntimeDefaults.reasoningSummaryFormat" + defaultReasoningSummarySource: "codexRuntimeDefaults.defaultReasoningSummary" }) ).toEqual({ diagnostic: { @@ -60,15 +60,15 @@ describe("reasoning summary helpers", () => { explicitSource: "request.reasoning.summary", hasReasoning: true, supportsReasoningSummaries: true, - defaultReasoningSummaryFormat: "experimental", - defaultReasoningSummarySource: "codexRuntimeDefaults.reasoningSummaryFormat", + defaultReasoningSummary: "experimental", + defaultReasoningSummarySource: "codexRuntimeDefaults.defaultReasoningSummary", model: "gpt-5.3-codex" }) ).toEqual({ diagnostic: { actual: "experimental", model: "gpt-5.3-codex", - source: "codexRuntimeDefaults.reasoningSummaryFormat", + source: "codexRuntimeDefaults.defaultReasoningSummary", sourceType: "catalog_default" } }) @@ -78,7 +78,7 @@ describe("reasoning summary helpers", () => { explicitSource: "request.reasoning.summary", hasReasoning: true, supportsReasoningSummaries: true, - defaultReasoningSummarySource: "codexRuntimeDefaults.reasoningSummaryFormat" + defaultReasoningSummarySource: "codexRuntimeDefaults.defaultReasoningSummary" }) ).toEqual({ value: "auto" }) }) @@ -95,7 +95,7 @@ describe("reasoning summary helpers", () => { const catalogError = toReasoningSummaryPluginFatalError({ actual: "experimental", model: "gpt-5.3-codex", - source: "codexRuntimeDefaults.reasoningSummaryFormat", + source: "codexRuntimeDefaults.defaultReasoningSummary", sourceType: "catalog_default" }) expect(catalogError.message).toContain("selected model catalog default") diff --git a/test/release-hygiene.test.ts b/test/release-hygiene.test.ts index 53b5128..e6c5c60 100644 --- a/test/release-hygiene.test.ts +++ b/test/release-hygiene.test.ts @@ -171,15 +171,8 @@ describe("release hygiene", () => { expect(securityAuditBlock).toContain("npm audit --audit-level=high") }) - it("keeps dependency review and secret scanning on pull requests", () => { - const dependencyReviewWorkflow = readFileSync( - join(process.cwd(), ".github", "workflows", "dependency-review.yml"), - "utf-8" - ) + it("keeps secret scanning on pull requests", () => { const secretScanWorkflow = readFileSync(join(process.cwd(), ".github", "workflows", "secret-scan.yml"), "utf-8") - expect(dependencyReviewWorkflow).toMatch(/on:\s*\n\s+pull_request:/) - expect(dependencyReviewWorkflow).toContain("name: Dependency Review") - expect(dependencyReviewWorkflow).toContain("exit 1") expect(secretScanWorkflow).toMatch(/on:\s*\n\s+push:\s*\n\s+branches:\s*\n\s+-\s+main\s*\n\s+pull_request:/) expect(secretScanWorkflow).toContain("name: Secret Scan") expect(secretScanWorkflow).toContain("name: Gitleaks") @@ -223,7 +216,7 @@ describe("release hygiene", () => { it("all workflows pin external actions by commit SHA", () => { const workflowsDir = join(process.cwd(), ".github", "workflows") - const files = ["ci.yml", "dependency-review.yml", "release.yml", "secret-scan.yml", "upstream-watch.yml"] + const files = ["ci.yml", "release.yml", "secret-scan.yml", "upstream-watch.yml"] for (const file of files) { const content = readFileSync(join(workflowsDir, file), "utf-8") @@ -241,7 +234,7 @@ describe("release hygiene", () => { it("all workflows define timeout-minutes for each job", () => { const workflowsDir = join(process.cwd(), ".github", "workflows") - const files = ["ci.yml", "dependency-review.yml", "release.yml", "secret-scan.yml", "upstream-watch.yml"] + const files = ["ci.yml", "release.yml", "secret-scan.yml", "upstream-watch.yml"] for (const file of files) { const content = readFileSync(join(workflowsDir, file), "utf-8")