diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2db114f6..e3c4f0b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,9 @@ jobs: - name: Security audit (CI policy) run: npm run audit:ci + - name: Lockfile floor guard + run: npm run test -- test/lockfile-version-floor.test.ts + - name: Security audit (full dependency tree, non-blocking) continue-on-error: true run: npm run audit:all diff --git a/.gitignore b/.gitignore index 6377e633..3e690535 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ opencode.json .opencode/ .omx/ tmp +.tmp tmp* .tmp*/ .tmp-*/ diff --git a/README.md b/README.md index 346d2c02..ab8542ea 100644 --- a/README.md +++ b/README.md @@ -129,11 +129,6 @@ codex auth doctor --fix | `codex auth fix --live --model gpt-5-codex` | Run repairs with live probe model | | `codex auth doctor --fix` | Diagnose and apply safe fixes | -Compatibility aliases are also supported: -- `codex multi auth ...` -- `codex multi-auth ...` -- `codex multiauth ...` - --- ## Dashboard Hotkeys @@ -226,7 +221,7 @@ codex auth login
Common symptoms -- `codex auth` unrecognized: run `where codex`, then try `codex multi auth status` +- `codex auth` unrecognized: run `where codex`, then follow `docs/troubleshooting.md` for routing fallback commands - Switch succeeds but wrong account appears active: run `codex auth switch `, then restart session - OAuth callback on port `1455` fails: free the port and re-run `codex auth login` - `missing field id_token` / `token_expired` / `refresh_token_reused`: re-login affected account @@ -259,6 +254,8 @@ codex auth doctor --json - Configuration: [docs/configuration.md](docs/configuration.md) - Troubleshooting: [docs/troubleshooting.md](docs/troubleshooting.md) - Commands reference: [docs/reference/commands.md](docs/reference/commands.md) +- Public API contract: [docs/reference/public-api.md](docs/reference/public-api.md) +- Error contracts: [docs/reference/error-contracts.md](docs/reference/error-contracts.md) - Settings reference: [docs/reference/settings.md](docs/reference/settings.md) - Storage paths: [docs/reference/storage-paths.md](docs/reference/storage-paths.md) - Upgrade guide: [docs/upgrade.md](docs/upgrade.md) @@ -268,9 +265,9 @@ codex auth doctor --json ## Release Notes -- Current stable: [docs/releases/v0.1.3.md](docs/releases/v0.1.3.md) -- Previous stable: [docs/releases/v0.1.2.md](docs/releases/v0.1.2.md) -- Earlier stable: [docs/releases/v0.1.1.md](docs/releases/v0.1.1.md) +- Current stable: [docs/releases/v0.1.4.md](docs/releases/v0.1.4.md) +- Previous stable: [docs/releases/v0.1.3.md](docs/releases/v0.1.3.md) +- Earlier stable: [docs/releases/v0.1.2.md](docs/releases/v0.1.2.md) - Archived prerelease: [docs/releases/v0.1.0-beta.0.md](docs/releases/v0.1.0-beta.0.md) ## License diff --git a/SECURITY.md b/SECURITY.md index 12f0082f..7d706885 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -75,6 +75,11 @@ The following are not treated as vulnerabilities in this repository: ## Dependency and Release Hygiene +Security override rationale (`package.json` -> `overrides`): + +- `hono`: pinned to `^4.12.3` to keep builds out of the vulnerable `4.12.0-4.12.1` range reported in `GHSA-xh87-mx6m-69f3` (authentication bypass advisory). +- `rollup`: pinned to `^4.59.0` to keep the Vite and Vitest transitive graph above the vulnerable `<4.59.0` range surfaced by `npm audit`. + Before release and after dependency changes: ```bash @@ -94,4 +99,4 @@ For non-vulnerability security questions, open a GitHub discussion. --- This project is not affiliated with OpenAI. -For OpenAI platform security concerns, contact OpenAI directly. \ No newline at end of file +For OpenAI platform security concerns, contact OpenAI directly. diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md index a4227c7b..8e8b0edc 100644 --- a/docs/DOCUMENTATION.md +++ b/docs/DOCUMENTATION.md @@ -29,11 +29,14 @@ Canonical governance for repository documentation quality and consistency. | Privacy and data handling | `docs/privacy.md` | | Upgrade and migration | `docs/upgrade.md` | | Command reference | `docs/reference/commands.md` | +| Public API contract | `docs/reference/public-api.md` | +| Error contract reference | `docs/reference/error-contracts.md` | | Settings reference | `docs/reference/settings.md` | | Storage path reference | `docs/reference/storage-paths.md` | | Docs style contract | `docs/STYLE_GUIDE.md` | | Docs governance (this file) | `docs/DOCUMENTATION.md` | | Architecture internals | `docs/development/ARCHITECTURE.md` | +| IA/findability audit (2026-03-01) | `docs/development/IA_FINDABILITY_AUDIT_2026-03-01.md` | | Config fields internals | `docs/development/CONFIG_FIELDS.md` | | Config flow internals | `docs/development/CONFIG_FLOW.md` | | Repository ownership map | `docs/development/REPOSITORY_SCOPE.md` | @@ -48,8 +51,9 @@ Canonical governance for repository documentation quality and consistency. 1. Canonical package name: `codex-multi-auth`. 2. Canonical account command family: `codex auth ...`. 3. Canonical storage root: `~/.codex/multi-auth` unless explicitly overridden. -4. Legacy paths/flows belong only in migration and compatibility sections. -5. Public release line is `0.x`; historical pre-`0.1.0` entries are archived separately. +4. Compatibility aliases (`codex multi auth`, `codex multi-auth`, `codex multiauth`) belong only in command reference, troubleshooting, or migration sections. +5. Legacy paths/flows and scoped package references belong only in migration and compatibility sections. +6. Public release line is `0.x`; historical pre-`0.1.0` entries are archived separately. --- diff --git a/docs/README.md b/docs/README.md index a44516af..460fbaea 100644 --- a/docs/README.md +++ b/docs/README.md @@ -24,9 +24,9 @@ Canonical documentation map for `codex-multi-auth`. | [troubleshooting.md](troubleshooting.md) | Deterministic recovery playbooks | | [privacy.md](privacy.md) | Data handling and local storage behavior | | [upgrade.md](upgrade.md) | Migration from legacy package/path history | -| [releases/v0.1.3.md](releases/v0.1.3.md) | Stable release notes | -| [releases/v0.1.2.md](releases/v0.1.2.md) | Previous stable release notes | -| [releases/v0.1.1.md](releases/v0.1.1.md) | Earlier stable release notes | +| [releases/v0.1.4.md](releases/v0.1.4.md) | Stable release notes | +| [releases/v0.1.3.md](releases/v0.1.3.md) | Previous stable release notes | +| [releases/v0.1.2.md](releases/v0.1.2.md) | Earlier stable release notes | | [releases/v0.1.0-beta.0.md](releases/v0.1.0-beta.0.md) | Archived prerelease notes | --- @@ -38,8 +38,11 @@ Canonical documentation map for `codex-multi-auth`. | [reference/commands.md](reference/commands.md) | Commands, flags, and hotkeys | | [reference/settings.md](reference/settings.md) | Dashboard/backend settings and defaults | | [reference/storage-paths.md](reference/storage-paths.md) | Canonical and compatibility storage paths | -| [releases/v0.1.3.md](releases/v0.1.3.md) | Current stable release notes | +| [reference/public-api.md](reference/public-api.md) | Tiered public API stability and semver contract | +| [reference/error-contracts.md](reference/error-contracts.md) | CLI, JSON, and helper error semantics contract | +| [releases/v0.1.4.md](releases/v0.1.4.md) | Current stable release notes | | [releases/v0.1.0-beta.0.md](releases/v0.1.0-beta.0.md) | Archived prerelease reference | +| [User Guides release notes](#user-guides) | Stable, previous, and archived release notes | | [releases/legacy-pre-0.1-history.md](releases/legacy-pre-0.1-history.md) | Archived pre-0.1 changelog history | --- @@ -50,6 +53,7 @@ Canonical documentation map for `codex-multi-auth`. | --- | --- | | [DOCUMENTATION.md](DOCUMENTATION.md) | Documentation governance contract | | [development/ARCHITECTURE.md](development/ARCHITECTURE.md) | Runtime architecture and invariants | +| [development/IA_FINDABILITY_AUDIT_2026-03-01.md](development/IA_FINDABILITY_AUDIT_2026-03-01.md) | IA/findability baseline, mismatches, and migration plan | | [development/CONFIG_FIELDS.md](development/CONFIG_FIELDS.md) | Complete field and env inventory | | [development/CONFIG_FLOW.md](development/CONFIG_FLOW.md) | Configuration resolution flow | | [development/REPOSITORY_SCOPE.md](development/REPOSITORY_SCOPE.md) | Ownership map by repository path | diff --git a/docs/STYLE_GUIDE.md b/docs/STYLE_GUIDE.md index 9b563b06..5e0d3e95 100644 --- a/docs/STYLE_GUIDE.md +++ b/docs/STYLE_GUIDE.md @@ -42,7 +42,8 @@ Use short sections and scan-friendly tables where they improve clarity. 1. Canonical command family is `codex auth ...`. 2. Canonical runtime root is `~/.codex/multi-auth`. 3. Legacy command/path references belong only in migration contexts. -4. Keep command flags aligned with runtime usage text. +4. Compatibility aliases (`codex multi auth`, `codex multi-auth`, `codex multiauth`) belong only in command reference, troubleshooting, or migration contexts. +5. Keep command flags aligned with runtime usage text. --- diff --git a/docs/development/DEEP_AUDIT_2026-03-01.md b/docs/development/DEEP_AUDIT_2026-03-01.md new file mode 100644 index 00000000..ea04d2d1 --- /dev/null +++ b/docs/development/DEEP_AUDIT_2026-03-01.md @@ -0,0 +1,64 @@ +# Deep Audit Report (2026-03-01) + +## Scope + +- Full repository hardening audit from `origin/main` at commit `36cf5d4e5c4d30f5a98b44f5711379425c7c8b1a`. +- Runtime, test, docs/governance, and dependency surfaces. +- Executed in isolated worktree branch: `audit/deep-hardening-2026-03-01`. + +## Findings + +### AUD-001 (Blocker) - Documentation policy regression + +- Surface: docs integrity contract (`test/documentation.test.ts`). +- Evidence: `uses scoped package only in explicit legacy migration notes` failed. +- Root cause: `docs/releases/v0.1.1.md` contained a scoped package literal outside the allowlist. +- Resolution: replaced scoped literal with generic migration-only wording and explicit link to upgrade guide. +- Files: + - `docs/releases/v0.1.1.md` + +### AUD-002 (High) - Runtime dependency vulnerability (`hono`) + +- Surface: production dependency audit (`npm audit --omit=dev --audit-level=high`). +- Evidence: `hono` high severity advisory (vulnerable range included locked version). +- Resolution: + - Raised direct dependency floor to `^4.12.2`. + - Raised override floor to `^4.12.2`. + - Refreshed lockfile to patched resolved version. +- Files: + - `package.json` + - `package-lock.json` + +### AUD-003 (High, dev tooling) - Unexpected `rollup` vulnerability in audit CI + +- Surface: `npm run audit:dev:allowlist`. +- Evidence: high-severity `rollup` advisory was not allowlisted and failed `audit:ci`. +- Resolution: + - Added override `rollup: ^4.59.0`. + - Refreshed lockfile to patched resolved version. +- Files: + - `package.json` + - `package-lock.json` + +## Validation Evidence + +- `npm run lint` -> pass +- `npm run typecheck` -> pass +- `npm run build` -> pass +- `npm test` -> pass (`87` files, `2071` tests) +- `npm test -- test/documentation.test.ts` -> pass +- `npm run audit:ci` -> pass + - `audit:prod` reports `0` vulnerabilities + - `audit:dev:allowlist` reports only allowlisted `minimatch` highs + +## Architect Verification + +- Verdict: `APPROVE` (no blockers). +- Summary: + - Dependency strategy is minimal and compatible with current toolchain ranges. + - Docs change aligns with existing documentation integrity policy. + +## Residual Risk + +- Dev-only allowlisted `minimatch` findings remain visible in `audit:dev:allowlist`; currently non-blocking under repository policy. + diff --git a/docs/development/IA_FINDABILITY_AUDIT_2026-03-01.md b/docs/development/IA_FINDABILITY_AUDIT_2026-03-01.md new file mode 100644 index 00000000..51cb7d84 --- /dev/null +++ b/docs/development/IA_FINDABILITY_AUDIT_2026-03-01.md @@ -0,0 +1,150 @@ +# Information Architecture: CLI + Docs Findability Audit (2026-03-01) + +Scope: user-facing command taxonomy, runtime help labels, docs navigation hierarchy, and naming consistency. + +Evidence sources: +- Runtime command/help surfaces: `lib/codex-manager.ts`, `scripts/codex-routing.js` +- Docs navigation/reference surfaces: `README.md`, `docs/README.md`, `docs/reference/commands.md`, `docs/troubleshooting.md`, `docs/getting-started.md`, `docs/releases/v0.1.1.md` +- Governance/test contracts: `docs/DOCUMENTATION.md`, `docs/STYLE_GUIDE.md`, `test/documentation.test.ts` + +--- + +## Current Structure + +### Runtime command taxonomy (current) + +- `codex auth ` (canonical) + - Primary: `login`, `list`, `status`, `switch`, `check`, `features` + - Advanced: `verify-flagged`, `forecast`, `report`, `fix`, `doctor` +- Compatibility aliases: + - `codex multi auth ...` + - `codex multi-auth ...` + - `codex multiauth ...` +- Runtime usage labels before this audit mixed canonical and package-prefixed forms in help/error paths. + - Prior `printUsage()` output in `lib/codex-manager.ts` used package-prefixed forms such as `codex-multi-auth auth fix [--dry-run] [--json] [--live] [--model ]`. + - Prior `runSwitch()` error text in `lib/codex-manager.ts` used `Missing index. Usage: codex-multi-auth auth switch `. + - Post-fix regression baseline is now asserted in `test/documentation.test.ts` by checking canonical `codex auth ...` usage and switch-error strings. + - Canonical baseline strings now used in runtime output are `codex auth fix [--dry-run] [--json] [--live] [--model ]` and `Missing index. Usage: codex auth switch `. + +### Docs hierarchy (current) + +- Product entry + - `README.md` +- Docs portal + - `docs/README.md` +- User operations + - `docs/index.md` + - `docs/getting-started.md` + - `docs/troubleshooting.md` + - `docs/configuration.md` + - `docs/features.md` + - `docs/privacy.md` + - `docs/upgrade.md` +- Reference + - `docs/reference/commands.md` + - `docs/reference/settings.md` + - `docs/reference/storage-paths.md` +- Releases + - `docs/releases/v0.1.1.md` + - `docs/releases/v0.1.0.md` + - `docs/releases/v0.1.0-beta.0.md` + - `docs/releases/legacy-pre-0.1-history.md` + +Hierarchy depth is 3 or fewer levels. + +--- + +## Task-to-Location Mapping (Current) + +Scoring rubric: +- `Match`: task is discoverable in the expected location within one navigation hop. +- `Near-miss`: task is discoverable but appears in unexpected locations or requires extra context-switch hops. +- `Lost`: task is not discoverable through expected navigation. + +| User Task | Expected Location | Actual Location | Findability | +| --- | --- | --- | --- | +| Log in first account | `README.md` quick start / `docs/getting-started.md` | Match | Match | +| Find all auth commands and flags | `docs/reference/commands.md` | Match | Match | +| Understand alias availability | `docs/reference/commands.md` (or troubleshooting fallback) | Also shown in `README.md` and `docs/getting-started.md` | Near-miss | +| Interpret CLI usage output | Canonical `codex auth ...` labels | Mixed with `codex-multi-auth auth ...` in runtime usage strings | Near-miss | +| Check current stable release notes | `docs/releases/v0.1.1.md` via docs portal reference | `docs/README.md` reference table labeled `v0.1.0` as current stable | Near-miss | +| Find scoped legacy package guidance | Migration docs only (`docs/upgrade.md`, selected troubleshooting) | Also surfaced in stable release notes `docs/releases/v0.1.1.md` | Near-miss | + +Findability score (core tasks): 2/6 clear first-attempt match. + +Verification evidence snapshot (2026-03-01): +- Runtime source checks in `lib/codex-manager.ts` confirm canonical `codex auth ...` usage labels and switch-error wording. +- Documentation checks in `test/documentation.test.ts` validate stable release pointer correctness and alias-scope allowlists. +- Alias detection checks are case-insensitive to prevent false negatives on mixed-case docs labels. + +Near-miss to remediation traceability: +- `Understand alias availability` -> resolved by scoping aliases to reference/troubleshooting/migration surfaces and removing alias examples from primary onboarding flows. +- `Interpret CLI usage output` -> resolved by canonicalizing runtime help and error usage strings to `codex auth ...` in `lib/codex-manager.ts`. +- `Check current stable release notes` -> resolved by updating docs portal stable pointer to `docs/releases/v0.1.1.md`. +- `Find scoped legacy package guidance` -> resolved by keeping scoped-package references in migration contexts and removing them from stable release notes. + +--- + +## Naming Inconsistencies Found + +| Concept | Variant A | Variant B | Recommended | +| --- | --- | --- | --- | +| Canonical command label | `codex auth ...` | `codex-multi-auth auth ...` | `codex auth ...` for all primary user-facing help text | +| Alias placement policy | Reference/troubleshooting intent | Also in primary README/getting-started command flows | Keep aliases in reference/troubleshooting/migration contexts only | +| Stable release pointer | `v0.1.1` in user guides | `v0.1.0` labeled current stable in docs reference table | Use `v0.1.1` as current stable consistently | +| Scoped legacy package mention | Migration-only contexts | Stable release notes mention | Keep scoped package guidance migration-only | + +--- + +## Proposed Structure + +### Navigation model + +- Keep existing shallow hierarchy and layer model. +- Enforce one canonical location per task category: + - "How to run commands": `docs/reference/commands.md` + - "Fallback routing or alias recovery": `docs/troubleshooting.md` + - "Migration from legacy package/path": `docs/upgrade.md` + - "Current stable release": `docs/releases/v0.1.1.md` + +### Labeling model + +- Canonical command wording in runtime help/error text: `codex auth ...` +- Compatibility alias wording restricted to reference/troubleshooting/migration sections. +- Scoped legacy package guidance restricted to migration contexts. + +--- + +## Migration Path + +1. Canonicalize runtime usage/error strings to `codex auth ...`. +2. Remove alias examples from primary README/onboarding flows; keep fallback routing guidance in troubleshooting/reference. +3. Correct docs portal reference table to current stable release (`v0.1.1`). +4. Remove scoped package mention from stable release notes and point to upgrade guide for migration details. +5. Maintain deterministic regression checks in `test/documentation.test.ts`: + - `uses scoped package only in explicit legacy migration notes` (`test/documentation.test.ts:104`) enforces scoped package boundaries. + - `keeps compatibility command aliases scoped to reference, troubleshooting, or migration docs` (`test/documentation.test.ts:127`) enforces alias-visibility boundaries with explicit allowlist files. + - `keeps fix command flag docs aligned across README, reference, and CLI usage text` (`test/documentation.test.ts:160`) enforces canonical runtime help/error wording. + - Keep cross-platform verification requirements explicit: Windows-oriented validation patterns (for example HOME/USERPROFILE handling and Windows path guidance checks in `test/documentation.test.ts:244-245`) must be extended whenever new shell-sensitive command rendering is introduced, including explicit `codex auth` output-escaping checks for `cmd.exe` and `PowerShell`. + +--- + +## Task-to-Location Mapping (Proposed) + +| User Task | Location | Findability Improvement | +| --- | --- | --- | +| Run login/switch/check commands | `README.md` and `docs/getting-started.md` with canonical labels | Removes mixed labels in first-run paths | +| Discover full command/flag matrix | `docs/reference/commands.md` | Retains single authoritative command catalog | +| Recover from command routing problems | `docs/troubleshooting.md` | Alias fallback remains discoverable but contextual | +| Verify current stable release | `docs/README.md` -> `docs/releases/v0.1.1.md` | Eliminates stale stable-pointer ambiguity | +| Migrate from scoped legacy package | `docs/upgrade.md` | Prevents legacy naming bleed into stable operational docs | + +Target findability score for core tasks after remediation: 6/6 first-attempt match. + +--- + +## Out of Scope + +- Visual design or formatting redesign. +- Runtime behavior changes to command routing/alias support. +- Internal module naming unrelated to user-facing findability. diff --git a/docs/getting-started.md b/docs/getting-started.md index 1c893ec3..629fe5eb 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -89,9 +89,10 @@ If `codex auth` is not recognized: ```bash where codex -codex multi auth status ``` +Then continue with [troubleshooting.md](troubleshooting.md#verify-install-and-routing) for routing fallback commands. + If OAuth callback on `1455` fails: - Stop the process using port `1455`. @@ -111,4 +112,4 @@ codex auth check - [features.md](features.md) - [configuration.md](configuration.md) - [troubleshooting.md](troubleshooting.md) -- [reference/commands.md](reference/commands.md) \ No newline at end of file +- [reference/commands.md](reference/commands.md) diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 0863b83a..43c877fa 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -115,5 +115,7 @@ codex auth doctor --fix ## Related - [../features.md](../features.md) +- [public-api.md](public-api.md) +- [error-contracts.md](error-contracts.md) - [settings.md](settings.md) -- [../troubleshooting.md](../troubleshooting.md) \ No newline at end of file +- [../troubleshooting.md](../troubleshooting.md) diff --git a/docs/reference/error-contracts.md b/docs/reference/error-contracts.md new file mode 100644 index 00000000..62694d4f --- /dev/null +++ b/docs/reference/error-contracts.md @@ -0,0 +1,87 @@ +# Error Contract Reference + +Error contract reference for user-facing CLI and exported helper behavior. + +--- + +## CLI Error Contract + +### Exit Codes + +- `0`: successful execution +- `1`: usage error, invalid arguments, sync/persistence failure, or command failure + +### Streams + +- Human-readable command output is written to `stdout`. +- Argument/usage and failure diagnostics are written to `stderr`. +- On invalid command/arguments, usage text is printed with a non-zero exit code. + +### Canonical Usage Errors + +Examples: + +- unknown subcommand: `Unknown command: ` plus usage +- `switch` with missing index: `Missing index. Usage: codex auth switch ` +- `switch` with invalid index: `Invalid index: ` + +--- + +## JSON Mode Contract + +The following commands support `--json` and produce pretty-printed JSON objects: + +- `codex auth forecast --json` +- `codex auth report --json` +- `codex auth fix --json` +- `codex auth doctor --json` +- `codex auth verify-flagged --json` + +Compatibility guarantees: + +- Output is valid JSON. +- `command` field identifies the command family. +- Documented top-level sections remain stable unless a migration note is provided. + +--- + +## HTTP/Error Mapping Contract (Fetch Helpers) + +### Entitlement Mapping + +- Upstream entitlement-like 404 payloads are normalized to `403` with `entitlement_error` payloads. +- Entitlement errors are not treated as rate limits. + +### Rate-Limit Mapping + +- Upstream usage-limit indicators normalize to rate-limit semantics. +- `handleErrorResponse` may return parsed `rateLimit.retryAfterMs` metadata. + +### Response Normalization + +- Error responses are normalized to JSON error payloads with a stable `error.message` field. +- Diagnostics may include request/correlation IDs when available. + +--- + +## Options-Object Compatibility Contract + +For selected exported helper APIs, options-object forms were added without removing positional signatures. + +Supported dual-call forms include: + +- `selectHybridAccount(...)` and `selectHybridAccount({ ... })` +- `exponentialBackoff(...)` and `exponentialBackoff({ ... })` +- `getTopCandidates(...)` and `getTopCandidates({ ... })` +- `createCodexHeaders(...)` and `createCodexHeaders({ ... })` +- `getRateLimitBackoffWithReason(...)` and `getRateLimitBackoffWithReason({ ... })` +- `transformRequestBody(...)` and `transformRequestBody({ ... })` + +--- + +## Related + +- [public-api.md](public-api.md) +- [commands.md](commands.md) +- [../troubleshooting.md](../troubleshooting.md) +- [../upgrade.md](../upgrade.md) diff --git a/docs/reference/public-api.md b/docs/reference/public-api.md new file mode 100644 index 00000000..865189ff --- /dev/null +++ b/docs/reference/public-api.md @@ -0,0 +1,98 @@ +# Public API Contract + +Public API contract for `codex-multi-auth`. + +--- + +## Stability Tiers + +This project uses tiered API stability. + +### Tier A: Stable APIs + +Stable APIs are covered by semver compatibility guarantees and must remain backward-compatible inside the `0.x` line unless explicitly documented. + +- Package root plugin entrypoint exports: + - `OpenAIOAuthPlugin` + - `OpenAIAuthPlugin` + - default export (alias of `OpenAIOAuthPlugin`) +- CLI surface: + - `codex auth ...` command family + - documented flags and aliases in `reference/commands.md` +- Persistent user-facing config and storage contracts documented in: + - `reference/settings.md` + - `reference/storage-paths.md` + +### Tier B: Compatibility APIs + +Compatibility APIs are exported for ecosystem continuity but are not treated as first-class product entrypoints. + +- Deep module exports from `dist/lib/index.js` and `lib/index.ts` barrel re-exports. +- Existing positional signatures remain supported. +- New options-object alternatives are preferred for new callers. + +Compatibility policy for Tier B: + +- Additive changes are allowed. +- Existing exported symbols must not be removed in this release line. +- Deprecated usage may be documented, but hard removals require a major version transition plan. + +### Tier C: Internal APIs + +Internal APIs are any non-exported internals and implementation details not covered by Tier A or Tier B. + +- No compatibility guarantee. +- May change at any time if Tier A/Tier B behavior remains intact. + +--- + +## Preferred Calling Style + +For exported functions with many positional parameters, use options-object forms when available. + +Examples of additive options-object alternatives: + +- `selectHybridAccount({ ... })` +- `exponentialBackoff({ ... })` +- `getTopCandidates({ ... })` +- `createCodexHeaders({ ... })` +- `getRateLimitBackoffWithReason({ ... })` +- `transformRequestBody({ ... })` + +Positional signatures are preserved for backward compatibility. + +--- + +## Semver Guidance + +- Breaking Tier A change: `MAJOR` +- Additive Tier A change: `MINOR` +- Tier A bug fix or doc-only clarification: `PATCH` +- Tier B additive compatibility improvement: usually `PATCH` or `MINOR` depending on caller impact + +This repository currently ships on a `0.x` line, but breaking changes still require explicit migration documentation and review sign-off. + +--- + +## Migration Rules + +For any future intentional contract break: + +1. Identify affected callers and command workflows. +2. Provide migration path with concrete before/after examples. +3. Update: + - `README.md` + - `docs/upgrade.md` + - affected `docs/reference/*` + - release notes and changelog +4. Add tests proving both old and new behavior during transition windows when feasible. + +--- + +## Related + +- [commands.md](commands.md) +- [error-contracts.md](error-contracts.md) +- [settings.md](settings.md) +- [storage-paths.md](storage-paths.md) +- [../upgrade.md](../upgrade.md) diff --git a/docs/releases/v0.1.4.md b/docs/releases/v0.1.4.md new file mode 100644 index 00000000..cf69c0b0 --- /dev/null +++ b/docs/releases/v0.1.4.md @@ -0,0 +1,62 @@ +# Release v0.1.4 + +Release date: 2026-03-03 +Channel: `latest` + +## Highlights + +- Merged PR #27 hardening sweep into the main release line. +- Stabilized `codex auth switch ` + host auth sync behavior so local account selection remains deterministic while sync failures are reported clearly. +- Hardened refresh token normalization and refresh queue stale/timeout recovery paths to reduce stuck lanes and duplicate refresh churn. +- Expanded deterministic regression coverage across auth, refresh queue, docs integrity, retry/backoff, and CLI routing paths. +- Added script-level audit allowlist regression coverage for advisory-source edge cases and Windows `cmd.exe` execution path. + +## Install + +```bash +npm i -g @openai/codex +npm i -g codex-multi-auth +``` + +## Core Operations + +```bash +codex auth login +codex auth list +codex auth switch 2 +codex auth status +codex auth check +codex auth forecast --live +``` + +## Validation Snapshot + +Release gate commands: + +- `npm run lint` +- `npm run typecheck` +- `npm run build` +- `npm test` +- `npm link` +- `codex auth --help` +- `codex auth features` + +## Merged PRs + +- PR #27: Unified supersede branch with final review remediations and release hardening + +## Commits + +- Included in release tag `v0.1.4`. + +## Notes + +- Local multi-auth routing remains the source of truth for account selection. +- CLI help and release docs now point to `v0.1.4` as current stable. +- Canonical runtime paths remain under `~/.codex/multi-auth`. + +## Related + +- [../getting-started.md](../getting-started.md) +- [../upgrade.md](../upgrade.md) +- [../reference/commands.md](../reference/commands.md) diff --git a/index.ts b/index.ts index d7dd5557..7db88088 100644 --- a/index.ts +++ b/index.ts @@ -259,11 +259,46 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { return "balanced"; }; - const parseEnvInt = (value: string | undefined): number | undefined => { - if (value === undefined) return undefined; - const parsed = Number.parseInt(value, 10); - return Number.isFinite(parsed) ? parsed : undefined; - }; + const parseEnvInt = (value: string | undefined): number | undefined => { + if (value === undefined) return undefined; + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : undefined; + }; + + const MAX_RETRY_HINT_MS = 5 * 60 * 1000; + const clampRetryHintMs = (value: number): number | null => { + if (!Number.isFinite(value)) return null; + const normalized = Math.floor(value); + if (normalized <= 0) return null; + return Math.min(normalized, MAX_RETRY_HINT_MS); + }; + + const parseRetryAfterHintMs = (headers: Headers): number | null => { + const retryAfterMsHeader = headers.get("retry-after-ms")?.trim(); + if (retryAfterMsHeader && /^\d+$/.test(retryAfterMsHeader)) { + return clampRetryHintMs(Number.parseInt(retryAfterMsHeader, 10)); + } + + const retryAfterHeader = headers.get("retry-after")?.trim(); + if (retryAfterHeader && /^\d+$/.test(retryAfterHeader)) { + return clampRetryHintMs(Number.parseInt(retryAfterHeader, 10) * 1000); + } + if (retryAfterHeader) { + const retryAtMs = Date.parse(retryAfterHeader); + if (Number.isFinite(retryAtMs)) { + return clampRetryHintMs(retryAtMs - Date.now()); + } + } + + const resetAtHeader = headers.get("x-ratelimit-reset")?.trim(); + if (resetAtHeader && /^\d+$/.test(resetAtHeader)) { + const resetRaw = Number.parseInt(resetAtHeader, 10); + const resetAtMs = resetRaw < 10_000_000_000 ? resetRaw * 1000 : resetRaw; + return clampRetryHintMs(resetAtMs - Date.now()); + } + + return null; + }; const sanitizeResponseHeadersForLog = (headers: Headers): Record => { const allowed = new Set([ @@ -1587,18 +1622,18 @@ while (attempted.size < Math.max(1, accountCount)) { continue; } - // Consume a token before making the request for proactive rate limiting - const tokenConsumed = accountManager.consumeToken(account, modelFamily, model); - if (!tokenConsumed) { - accountManager.recordRateLimit(account, modelFamily, model); - runtimeMetrics.accountRotations++; + // Consume a token before making the request for proactive rate limiting + const tokenConsumed = accountManager.consumeToken(account, modelFamily, model); + if (!tokenConsumed) { + accountManager.recordRateLimit(account, modelFamily, model); + runtimeMetrics.accountRotations++; runtimeMetrics.lastError = `Local token bucket depleted for account ${account.index + 1} (${modelFamily}${model ? `:${model}` : ""})`; - logWarn( - `Skipping account ${account.index + 1}: local token bucket depleted for ${modelFamily}${model ? `:${model}` : ""}`, - ); - break; - } + logWarn( + `Skipping account ${account.index + 1}: local token bucket depleted for ${modelFamily}${model ? `:${model}` : ""}`, + ); + continue; + } let sameAccountRetryCount = 0; let successAccountForResponse = account; @@ -1609,10 +1644,12 @@ while (attempted.size < Math.max(1, accountCount)) { // Merge user AbortSignal with timeout (Node 18 compatible - no AbortSignal.any) const fetchController = new AbortController(); const requestTimeoutMs = fetchTimeoutMs; - const fetchTimeoutId = setTimeout( - () => fetchController.abort(new Error("Request timeout")), - requestTimeoutMs, - ); + let requestTimedOut = false; + const timeoutReason = new Error("Request timeout"); + const fetchTimeoutId = setTimeout(() => { + requestTimedOut = true; + fetchController.abort(timeoutReason); + }, requestTimeoutMs); const onUserAbort = abortSignal ? () => fetchController.abort(abortSignal.reason ?? new Error("Aborted by user")) @@ -1632,17 +1669,24 @@ while (attempted.size < Math.max(1, accountCount)) { headers, signal: fetchController.signal, }); - } catch (networkError) { - const isUserAbort = - abortSignal?.aborted || - (networkError instanceof Error && - (networkError.name === "AbortError" || /abort/i.test(networkError.message))); - if (isUserAbort) { - runtimeMetrics.userAborts++; - runtimeMetrics.lastError = "request aborted by user"; - sessionAffinityStore?.forgetSession(sessionAffinityKey); - break; - } + } catch (networkError) { + const fetchAbortReason = fetchController.signal.reason; + const isTimeoutAbort = + requestTimedOut || + (fetchAbortReason instanceof Error && + fetchAbortReason.message === timeoutReason.message); + const isUserAbort = Boolean(abortSignal?.aborted) && !isTimeoutAbort; + if (isUserAbort) { + accountManager.refundToken(account, modelFamily, model); + runtimeMetrics.userAborts++; + runtimeMetrics.lastError = "request aborted by user"; + sessionAffinityStore?.forgetSession(sessionAffinityKey); + throw ( + fetchAbortReason instanceof Error + ? fetchAbortReason + : new Error("Aborted by user") + ); + } const errorMsg = networkError instanceof Error ? networkError.message : String(networkError); logWarn(`Network error for account ${account.index + 1}: ${errorMsg}`); runtimeMetrics.failedRequests++; @@ -1910,17 +1954,18 @@ while (attempted.size < Math.max(1, accountCount)) { logDebug(`[${PLUGIN_NAME}] Recoverable error detected: ${errorType}`); } - // Handle 5xx server errors by rotating to another account - if (response.status >= 500 && response.status < 600) { - logWarn(`Server error ${response.status} for account ${account.index + 1}. Rotating to next account.`); - runtimeMetrics.failedRequests++; - runtimeMetrics.serverErrors++; - runtimeMetrics.accountRotations++; - runtimeMetrics.lastError = `HTTP ${response.status}`; - const policy = evaluateFailurePolicy( - { kind: "server", failoverMode }, - { serverCooldownMs: serverErrorCooldownMs }, - ); + // Handle 5xx server errors by rotating to another account + if (response.status >= 500 && response.status < 600) { + logWarn(`Server error ${response.status} for account ${account.index + 1}. Rotating to next account.`); + runtimeMetrics.failedRequests++; + runtimeMetrics.serverErrors++; + runtimeMetrics.accountRotations++; + runtimeMetrics.lastError = `HTTP ${response.status}`; + const serverRetryAfterMs = parseRetryAfterHintMs(response.headers); + const policy = evaluateFailurePolicy( + { kind: "server", failoverMode, serverRetryAfterMs: serverRetryAfterMs ?? undefined }, + { serverCooldownMs: serverErrorCooldownMs }, + ); if (policy.refundToken) { accountManager.refundToken(account, modelFamily, model); } @@ -2162,17 +2207,12 @@ while (attempted.size < Math.max(1, accountCount)) { } catch { // Best effort cleanup before trying next fallback account. } - if (fallbackResponse.status === 429) { - const retryAfterRaw = fallbackResponse.headers - .get("retry-after") - ?.trim(); - const retryAfterMs = - retryAfterRaw && /^\d+$/.test(retryAfterRaw) - ? Number.parseInt(retryAfterRaw, 10) * 1000 - : 60_000; - accountManager.markRateLimitedWithReason( - fallbackAccount, - retryAfterMs, + if (fallbackResponse.status === 429) { + const retryAfterMs = + parseRetryAfterHintMs(fallbackResponse.headers) ?? 60_000; + accountManager.markRateLimitedWithReason( + fallbackAccount, + retryAfterMs, modelFamily, "quota", model, diff --git a/lib/AGENTS.md b/lib/AGENTS.md index c9339de8..93d7afbd 100644 --- a/lib/AGENTS.md +++ b/lib/AGENTS.md @@ -101,7 +101,7 @@ lib/ ## WHERE TO LOOK | Task | Location | Notes | | --- | --- | --- | -| Token exchange/refresh | `auth/auth.ts` | PKCE flow, JWT decode, skew window, `REDIRECT_URI` = `127.0.0.1:1455` | +| Token exchange/refresh | `auth/auth.ts` | PKCE flow, JWT decode, skew window, `REDIRECT_URI` = `localhost:1455` | | Token validation | `auth/token-utils.ts` | expiry checks, parsing | | Browser launch | `auth/browser.ts` | platform-specific open | | Callback server | `auth/server.ts` | HTTP on port 1455 | diff --git a/lib/auth/auth.ts b/lib/auth/auth.ts index b99f9571..591a68ec 100644 --- a/lib/auth/auth.ts +++ b/lib/auth/auth.ts @@ -3,12 +3,13 @@ import { randomBytes } from "node:crypto"; import type { PKCEPair, AuthorizationFlow, TokenResult, ParsedAuthInput, JWTPayload } from "../types.js"; import { logError } from "../logger.js"; import { safeParseOAuthTokenResponse } from "../schemas.js"; +import { isAbortError } from "../utils.js"; // OAuth constants (from openai/codex) export const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; export const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize"; export const TOKEN_URL = "https://auth.openai.com/oauth/token"; -export const REDIRECT_URI = "http://127.0.0.1:1455/auth/callback"; +export const REDIRECT_URI = "http://localhost:1455/auth/callback"; export const SCOPE = "openid profile email offline_access"; const OAUTH_SENSITIVE_QUERY_PARAMS = [ @@ -18,6 +19,22 @@ const OAUTH_SENSITIVE_QUERY_PARAMS = [ "code_verifier", ] as const; +function getOAuthResponseLogMetadata(rawResponse: unknown): Record { + if (Array.isArray(rawResponse)) { + return { responseType: "array", itemCount: rawResponse.length }; + } + + if (rawResponse !== null && typeof rawResponse === "object") { + const allKeys = Object.keys(rawResponse as Record); + return { + responseType: "object", + keyCount: allKeys.length, + }; + } + + return { responseType: typeof rawResponse }; +} + /** * Redacts sensitive OAuth query parameters for safe logging. * Returns the original string when parsing fails. @@ -118,13 +135,22 @@ export async function exchangeAuthorizationCode( const rawJson = (await res.json()) as unknown; const json = safeParseOAuthTokenResponse(rawJson); if (!json) { - logError("token response validation failed", rawJson); + logError("token response validation failed", getOAuthResponseLogMetadata(rawJson)); return { type: "failed", reason: "invalid_response", message: "Response failed schema validation" }; } + if (!json.refresh_token || json.refresh_token.trim().length === 0) { + logError("token response missing refresh token", getOAuthResponseLogMetadata(rawJson)); + return { + type: "failed", + reason: "invalid_response", + message: "Missing refresh token in authorization code exchange response", + }; + } + const normalizedRefreshToken = json.refresh_token.trim(); return { type: "success", access: json.access_token, - refresh: json.refresh_token ?? "", + refresh: normalizedRefreshToken, expires: Date.now() + json.expires_in * 1000, idToken: json.id_token, multiAccount: true, @@ -158,11 +184,19 @@ export function decodeJWT(token: string): JWTPayload | null { * @param refreshToken - Refresh token * @returns Token result */ -export async function refreshAccessToken(refreshToken: string): Promise { +type RefreshAccessTokenOptions = { + signal?: AbortSignal; +}; + +export async function refreshAccessToken( + refreshToken: string, + options: RefreshAccessTokenOptions = {}, +): Promise { try { const response = await fetch(TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, + signal: options?.signal, body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, @@ -179,11 +213,12 @@ export async function refreshAccessToken(refreshToken: string): Promise { server - .listen(1455, "127.0.0.1", () => { + .listen(1455, "localhost", () => { resolve({ port: 1455, ready: true, @@ -101,7 +101,7 @@ export function startLocalOAuthServer({ state }: { state: string }): Promise { logError( - `Failed to bind http://127.0.0.1:1455 (${err?.code}). Falling back to manual paste.`, + `Failed to bind http://localhost:1455 (${err?.code}). Falling back to manual paste.`, ); resolve({ port: 1455, diff --git a/lib/codex-cli/state.ts b/lib/codex-cli/state.ts index 1380574b..8372f050 100644 --- a/lib/codex-cli/state.ts +++ b/lib/codex-cli/state.ts @@ -229,6 +229,20 @@ export function getCodexCliAuthPath(): string { return join(homedir(), ".codex", "auth.json"); } +/** + * Resolve the filesystem path for the Codex CLI config TOML file, allowing an environment override. + * + * If `CODEX_CLI_CONFIG_PATH` is set to a non-empty value (after trimming), that path is returned. + * Otherwise, defaults to `$HOME/.codex/config.toml`. + * + * @returns The resolved path to Codex CLI `config.toml`. + */ +export function getCodexCliConfigPath(): string { + const override = (process.env.CODEX_CLI_CONFIG_PATH ?? "").trim(); + if (override.length > 0) return override; + return join(homedir(), ".codex", "config.toml"); +} + /** * Convert a parsed Codex CLI accounts JSON payload into a CodexCliState snapshot. * diff --git a/lib/codex-cli/writer.ts b/lib/codex-cli/writer.ts index 1341962d..2829882c 100644 --- a/lib/codex-cli/writer.ts +++ b/lib/codex-cli/writer.ts @@ -5,6 +5,7 @@ import { clearCodexCliStateCache, getCodexCliAccountsPath, getCodexCliAuthPath, + getCodexCliConfigPath, isCodexCliSyncEnabled, } from "./state.js"; import { @@ -140,11 +141,11 @@ function toIsoTime(ms: number | undefined): string { return new Date().toISOString(); } -async function atomicWriteJson(path: string, payload: Record): Promise { +async function atomicWriteText(path: string, content: string): Promise { const tempPath = `${path}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`; await fs.mkdir(dirname(path), { recursive: true }); try { - await fs.writeFile(tempPath, JSON.stringify(payload, null, 2), { + await fs.writeFile(tempPath, content, { encoding: "utf-8", mode: 0o600, }); @@ -174,6 +175,54 @@ async function atomicWriteJson(path: string, payload: Record): } } +async function atomicWriteJson(path: string, payload: Record): Promise { + return atomicWriteText(path, JSON.stringify(payload, null, 2)); +} + +function shouldEnforceCodexCliFileAuthStore(): boolean { + const override = (process.env.CODEX_MULTI_AUTH_ENFORCE_CLI_FILE_AUTH_STORE ?? "1").trim(); + return override !== "0"; +} + +function ensureTrailingNewline(value: string): string { + return value.endsWith("\n") ? value : `${value}\n`; +} + +async function ensureCodexCliFileAuthStore(configPath: string): Promise { + if (!shouldEnforceCodexCliFileAuthStore()) return false; + + const desired = 'cli_auth_credentials_store = "file"'; + const raw = existsSync(configPath) ? await fs.readFile(configPath, "utf-8") : ""; + const lines = raw.length > 0 ? raw.split(/\r?\n/) : []; + const assignmentRegex = /^\s*cli_auth_credentials_store\s*=/; + + let replaced = false; + const nextLines = lines.map((line) => { + if (!replaced && assignmentRegex.test(line)) { + replaced = true; + return desired; + } + return line; + }); + + if (!replaced) { + let insertAt = nextLines.findIndex((line) => /^\s*\[/.test(line)); + if (insertAt < 0) insertAt = nextLines.length; + nextLines.splice(insertAt, 0, desired); + if (insertAt < nextLines.length - 1 && nextLines[insertAt + 1]?.trim().length !== 0) { + nextLines.splice(insertAt + 1, 0, ""); + } + } + + const nextRaw = ensureTrailingNewline(nextLines.join("\n")); + if (nextRaw === ensureTrailingNewline(raw)) { + return false; + } + + await atomicWriteText(configPath, nextRaw); + return true; +} + async function writeCodexAuthState( path: string, selection: ActiveSelection, @@ -196,12 +245,34 @@ async function writeCodexAuthState( const syncVersion = Date.now(); const selectedAccessToken = readTrimmedString(selection.accessToken); const selectedRefreshToken = readTrimmedString(selection.refreshToken); - const accessToken = - selectedAccessToken ?? - (typeof existingTokens.access_token === "string" ? existingTokens.access_token : undefined); - const refreshToken = - selectedRefreshToken ?? - (typeof existingTokens.refresh_token === "string" ? existingTokens.refresh_token : undefined); + const existingAccessToken = + typeof existingTokens.access_token === "string" ? existingTokens.access_token : undefined; + const existingRefreshToken = + typeof existingTokens.refresh_token === "string" ? existingTokens.refresh_token : undefined; + const hasSelectedAccess = typeof selectedAccessToken === "string" && selectedAccessToken.length > 0; + const hasSelectedRefresh = typeof selectedRefreshToken === "string" && selectedRefreshToken.length > 0; + const selectedTokenPair = hasSelectedAccess && hasSelectedRefresh; + let accessToken: string | undefined; + let refreshToken: string | undefined; + + if (selectedTokenPair) { + accessToken = selectedAccessToken; + refreshToken = selectedRefreshToken; + } else if (!hasSelectedAccess && !hasSelectedRefresh) { + accessToken = existingAccessToken; + refreshToken = existingRefreshToken; + } else { + log.warn("Failed to persist Codex auth selection", { + operation: "write-active-selection", + outcome: "partial-token-payload", + path, + accountRef: makeAccountFingerprint({ + accountId: selection.accountId, + email: selection.email, + }), + }); + return false; + } if (!accessToken || !refreshToken) { log.warn("Failed to persist Codex auth selection", { @@ -224,15 +295,17 @@ async function writeCodexAuthState( } nextTokens.access_token = accessToken; nextTokens.refresh_token = refreshToken; - const resolvedIdToken = - readTrimmedString(selection.idToken) ?? - (typeof existingTokens.id_token === "string" ? existingTokens.id_token : undefined); - if (resolvedIdToken) { - nextTokens.id_token = resolvedIdToken; - } if (selection.accountId?.trim()) { nextTokens.account_id = selection.accountId.trim(); } + const selectedIdToken = readTrimmedString(selection.idToken); + const existingIdToken = readTrimmedString(existingTokens.id_token); + const fallbackIdToken = selectedTokenPair ? accessToken : (existingIdToken ?? accessToken); + if (selectedIdToken) { + nextTokens.id_token = selectedIdToken; + } else if (fallbackIdToken) { + nextTokens.id_token = fallbackIdToken; + } next.tokens = nextTokens; next.last_refresh = toIsoTime(selection.expiresAt); next.codexMultiAuthSyncVersion = syncVersion; @@ -272,10 +345,16 @@ export async function setCodexCliActiveSelection( incrementCodexCliMetric("writeAttempts"); const accountsPath = getCodexCliAccountsPath(); const authPath = getCodexCliAuthPath(); + const configPath = getCodexCliConfigPath(); const hasAccountsPath = existsSync(accountsPath); const hasAuthPath = existsSync(authPath); + const selectionHasTokens = + typeof selection.accessToken === "string" && + selection.accessToken.trim().length > 0 && + typeof selection.refreshToken === "string" && + selection.refreshToken.trim().length > 0; - if (!hasAccountsPath && !hasAuthPath) { + if (!hasAccountsPath && !hasAuthPath && !selectionHasTokens) { incrementCodexCliMetric("writeFailures"); return false; } @@ -284,6 +363,7 @@ export async function setCodexCliActiveSelection( let resolvedSelection: ActiveSelection = { ...selection }; let wroteAccounts = false; let wroteAuth = false; + let wroteConfig = false; if (hasAccountsPath) { const raw = await fs.readFile(accountsPath, "utf-8"); @@ -380,7 +460,7 @@ export async function setCodexCliActiveSelection( } } - if (hasAuthPath) { + if (hasAuthPath || wroteAccounts || selectionHasTokens) { wroteAuth = await writeCodexAuthState(authPath, resolvedSelection); if (!wroteAuth) { if (!wroteAccounts) { @@ -409,12 +489,31 @@ export async function setCodexCliActiveSelection( } } + try { + wroteConfig = await ensureCodexCliFileAuthStore(configPath); + } catch (error) { + log.warn("Failed to persist Codex config file auth store", { + operation: "write-active-selection", + outcome: "config-auth-store-update-failed", + path: configPath, + error: String(error), + }); + } + if (wroteAccounts || wroteAuth) { clearCodexCliStateCache(); incrementCodexCliMetric("writeSuccesses"); return true; } + if (wroteConfig) { + log.debug("Persisted Codex config file auth store override", { + operation: "write-active-selection", + outcome: "config-updated-only", + path: configPath, + }); + } + incrementCodexCliMetric("writeFailures"); return false; } catch (error) { diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 27fb169f..794eb7c6 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -61,6 +61,11 @@ import { type FlaggedAccountMetadataV1, } from "./storage.js"; import type { AccountIdSource, TokenFailure, TokenResult } from "./types.js"; +import { + getCodexCliAuthPath, + getCodexCliConfigPath, + loadCodexCliState, +} from "./codex-cli/state.js"; import { setCodexCliActiveSelection } from "./codex-cli/writer.js"; import { ANSI } from "./ui/ansi.js"; import { UI_COPY } from "./ui/copy.js"; @@ -281,17 +286,17 @@ function printUsage(): void { "Codex Multi-Auth CLI", "", "Usage:", - " codex-multi-auth auth login", - " codex-multi-auth auth list", - " codex-multi-auth auth status", - " codex-multi-auth auth switch ", - " codex-multi-auth auth check", - " codex-multi-auth auth features", - " codex-multi-auth auth verify-flagged [--dry-run] [--json] [--no-restore]", - " codex-multi-auth auth forecast [--live] [--json] [--model ]", - " codex-multi-auth auth report [--live] [--json] [--model ] [--out ]", - " codex-multi-auth auth fix [--dry-run] [--json] [--live] [--model ]", - " codex-multi-auth auth doctor [--json] [--fix] [--dry-run]", + " codex auth login", + " codex auth list", + " codex auth status", + " codex auth switch ", + " codex auth check", + " codex auth features", + " codex auth verify-flagged [--dry-run] [--json] [--no-restore]", + " codex auth forecast [--live] [--json] [--model ]", + " codex auth report [--live] [--json] [--model ] [--out ]", + " codex auth fix [--dry-run] [--json] [--live] [--model ]", + " codex auth doctor [--json] [--fix] [--dry-run]", "", "Notes:", " - Uses ~/.codex/multi-auth/openai-codex-accounts.json", @@ -3237,6 +3242,109 @@ async function runDoctor(args: string[]): Promise { } } + const codexAuthPath = getCodexCliAuthPath(); + const codexConfigPath = getCodexCliConfigPath(); + let codexAuthEmail: string | undefined; + let codexAuthAccountId: string | undefined; + + addCheck({ + key: "codex-auth-file", + severity: existsSync(codexAuthPath) ? "ok" : "warn", + message: existsSync(codexAuthPath) + ? "Codex auth file found" + : "Codex auth file does not exist", + details: codexAuthPath, + }); + + if (existsSync(codexAuthPath)) { + try { + const raw = await fs.readFile(codexAuthPath, "utf-8"); + const parsed = JSON.parse(raw) as unknown; + if (parsed && typeof parsed === "object") { + const payload = parsed as Record; + const tokens = payload.tokens && typeof payload.tokens === "object" + ? (payload.tokens as Record) + : null; + const accessToken = tokens && typeof tokens.access_token === "string" + ? tokens.access_token + : undefined; + const idToken = tokens && typeof tokens.id_token === "string" + ? tokens.id_token + : undefined; + const accountIdFromFile = tokens && typeof tokens.account_id === "string" + ? tokens.account_id + : undefined; + const emailFromFile = typeof payload.email === "string" ? payload.email : undefined; + codexAuthEmail = sanitizeEmail(emailFromFile ?? extractAccountEmail(accessToken, idToken)); + codexAuthAccountId = accountIdFromFile ?? extractAccountId(accessToken); + } + addCheck({ + key: "codex-auth-readable", + severity: "ok", + message: "Codex auth file is readable", + details: + codexAuthEmail || codexAuthAccountId + ? `email=${codexAuthEmail ?? "unknown"}, accountId=${codexAuthAccountId ?? "unknown"}` + : undefined, + }); + } catch (error) { + addCheck({ + key: "codex-auth-readable", + severity: "error", + message: "Unable to read Codex auth file", + details: error instanceof Error ? error.message : String(error), + }); + } + } + + addCheck({ + key: "codex-config-file", + severity: existsSync(codexConfigPath) ? "ok" : "warn", + message: existsSync(codexConfigPath) + ? "Codex config file found" + : "Codex config file does not exist", + details: codexConfigPath, + }); + + let codexAuthStoreMode: string | undefined; + if (existsSync(codexConfigPath)) { + try { + const configRaw = await fs.readFile(codexConfigPath, "utf-8"); + const match = configRaw.match(/^\s*cli_auth_credentials_store\s*=\s*"([^"]+)"\s*$/m); + if (match?.[1]) { + codexAuthStoreMode = match[1].trim(); + } + } catch (error) { + addCheck({ + key: "codex-auth-store", + severity: "warn", + message: "Unable to read Codex auth-store config", + details: error instanceof Error ? error.message : String(error), + }); + } + } + if (!checks.some((check) => check.key === "codex-auth-store")) { + addCheck({ + key: "codex-auth-store", + severity: codexAuthStoreMode === "file" ? "ok" : "warn", + message: + codexAuthStoreMode === "file" + ? "Codex auth storage is set to file" + : "Codex auth storage is not explicitly set to file", + details: codexAuthStoreMode ? `mode=${codexAuthStoreMode}` : "mode=unset", + }); + } + + const codexCliState = await loadCodexCliState({ forceRefresh: true }); + addCheck({ + key: "codex-cli-state", + severity: codexCliState ? "ok" : "warn", + message: codexCliState + ? "Codex CLI state loaded" + : "Codex CLI state unavailable", + details: codexCliState?.path, + }); + const storage = await loadAccounts(); let fixChanged = false; let fixActions: DoctorFixAction[] = []; @@ -3372,6 +3480,117 @@ async function runDoctor(args: string[]): Promise { message: "Current account aligns with forecast recommendation", }); } + + if (activeExists) { + const activeAccount = storage.accounts[activeIndex]; + const managerActiveEmail = sanitizeEmail(activeAccount?.email); + const managerActiveAccountId = activeAccount?.accountId; + const codexActiveEmail = sanitizeEmail(codexCliState?.activeEmail) ?? codexAuthEmail; + const codexActiveAccountId = codexCliState?.activeAccountId ?? codexAuthAccountId; + const isEmailMismatch = + !!managerActiveEmail && + !!codexActiveEmail && + managerActiveEmail !== codexActiveEmail; + const isAccountIdMismatch = + !!managerActiveAccountId && + !!codexActiveAccountId && + managerActiveAccountId !== codexActiveAccountId; + + addCheck({ + key: "active-selection-sync", + severity: isEmailMismatch || isAccountIdMismatch ? "warn" : "ok", + message: + isEmailMismatch || isAccountIdMismatch + ? "Manager active account and Codex active account are not aligned" + : "Manager active account and Codex active account are aligned", + details: `manager=${managerActiveEmail ?? managerActiveAccountId ?? "unknown"} | codex=${codexActiveEmail ?? codexActiveAccountId ?? "unknown"}`, + }); + + if (options.fix && activeAccount) { + let syncAccessToken = activeAccount.accessToken; + let syncRefreshToken = activeAccount.refreshToken; + let syncExpiresAt = activeAccount.expiresAt; + let syncIdToken: string | undefined; + let storageChangedFromDoctorSync = false; + + if (!hasUsableAccessToken(activeAccount, now)) { + if (options.dryRun) { + fixActions.push({ + key: "doctor-refresh", + message: `Prepared active-account token refresh for account ${activeIndex + 1} (dry-run)`, + }); + } else { + const refreshResult = await queuedRefresh(activeAccount.refreshToken); + if (refreshResult.type === "success") { + const refreshedEmail = sanitizeEmail( + extractAccountEmail(refreshResult.access, refreshResult.idToken), + ); + const refreshedAccountId = extractAccountId(refreshResult.access); + activeAccount.accessToken = refreshResult.access; + activeAccount.refreshToken = refreshResult.refresh; + activeAccount.expiresAt = refreshResult.expires; + if (refreshedEmail) activeAccount.email = refreshedEmail; + if (refreshedAccountId) { + activeAccount.accountId = refreshedAccountId; + activeAccount.accountIdSource = "token"; + } + syncAccessToken = refreshResult.access; + syncRefreshToken = refreshResult.refresh; + syncExpiresAt = refreshResult.expires; + syncIdToken = refreshResult.idToken; + storageChangedFromDoctorSync = true; + fixActions.push({ + key: "doctor-refresh", + message: `Refreshed active account tokens for account ${activeIndex + 1}`, + }); + } else { + addCheck({ + key: "doctor-refresh", + severity: "warn", + message: "Unable to refresh active account before Codex sync", + details: normalizeFailureDetail(refreshResult.message, refreshResult.reason), + }); + } + } + } + + if (storageChangedFromDoctorSync) { + fixChanged = true; + if (!options.dryRun) { + await saveAccounts(storage); + } + } + + if (!options.dryRun) { + const synced = await setCodexCliActiveSelection({ + accountId: activeAccount.accountId, + email: activeAccount.email, + accessToken: syncAccessToken, + refreshToken: syncRefreshToken, + expiresAt: syncExpiresAt, + ...(syncIdToken ? { idToken: syncIdToken } : {}), + }); + if (synced) { + fixChanged = true; + fixActions.push({ + key: "codex-active-sync", + message: "Synced manager active account into Codex auth state", + }); + } else { + addCheck({ + key: "codex-active-sync", + severity: "warn", + message: "Failed to sync manager active account into Codex auth state", + }); + } + } else { + fixActions.push({ + key: "codex-active-sync", + message: "Prepared Codex active-account sync (dry-run)", + }); + } + } + } } const summary = checks.reduce( @@ -3652,7 +3871,7 @@ async function runSwitch(args: string[]): Promise { setStoragePath(null); const indexArg = args[0]; if (!indexArg) { - console.error("Missing index. Usage: codex-multi-auth auth switch "); + console.error("Missing index. Usage: codex auth switch "); return 1; } const parsed = Number.parseInt(indexArg, 10); @@ -3687,16 +3906,55 @@ async function runSwitch(args: string[]): Promise { if (wasDisabled) { account.enabled = true; } - account.lastUsed = Date.now(); + const switchNow = Date.now(); + let syncAccessToken = account.accessToken; + let syncRefreshToken = account.refreshToken; + let syncExpiresAt = account.expiresAt; + let syncIdToken: string | undefined; + + if (!hasUsableAccessToken(account, switchNow)) { + const refreshResult = await queuedRefresh(account.refreshToken); + if (refreshResult.type === "success") { + const tokenAccountId = extractAccountId(refreshResult.access); + const nextEmail = sanitizeEmail(extractAccountEmail(refreshResult.access, refreshResult.idToken)); + if (account.refreshToken !== refreshResult.refresh) { + account.refreshToken = refreshResult.refresh; + } + if (account.accessToken !== refreshResult.access) { + account.accessToken = refreshResult.access; + } + if (account.expiresAt !== refreshResult.expires) { + account.expiresAt = refreshResult.expires; + } + if (nextEmail && nextEmail !== account.email) { + account.email = nextEmail; + } + if (tokenAccountId && tokenAccountId !== account.accountId) { + account.accountId = tokenAccountId; + account.accountIdSource = "token"; + } + syncAccessToken = refreshResult.access; + syncRefreshToken = refreshResult.refresh; + syncExpiresAt = refreshResult.expires; + syncIdToken = refreshResult.idToken; + } else { + console.warn( + `Switch validation refresh failed for account ${parsed}: ${normalizeFailureDetail(refreshResult.message, refreshResult.reason)}.`, + ); + } + } + + account.lastUsed = switchNow; account.lastSwitchReason = "rotation"; await saveAccounts(storage); const synced = await setCodexCliActiveSelection({ accountId: account.accountId, email: account.email, - accessToken: account.accessToken, - refreshToken: account.refreshToken, - expiresAt: account.expiresAt, + accessToken: syncAccessToken, + refreshToken: syncRefreshToken, + expiresAt: syncExpiresAt, + ...(syncIdToken ? { idToken: syncIdToken } : {}), }); if (!synced) { console.warn( @@ -3710,6 +3968,77 @@ async function runSwitch(args: string[]): Promise { return 0; } +export async function autoSyncActiveAccountToCodex(): Promise { + setStoragePath(null); + const storage = await loadAccounts(); + if (!storage || storage.accounts.length === 0) { + return false; + } + + const activeIndex = resolveActiveIndex(storage, "codex"); + if (activeIndex < 0 || activeIndex >= storage.accounts.length) { + return false; + } + + const account = storage.accounts[activeIndex]; + if (!account) { + return false; + } + + const now = Date.now(); + let syncAccessToken = account.accessToken; + let syncRefreshToken = account.refreshToken; + let syncExpiresAt = account.expiresAt; + let syncIdToken: string | undefined; + let changed = false; + + if (!hasUsableAccessToken(account, now)) { + const refreshResult = await queuedRefresh(account.refreshToken); + if (refreshResult.type === "success") { + const tokenAccountId = extractAccountId(refreshResult.access); + const nextEmail = sanitizeEmail(extractAccountEmail(refreshResult.access, refreshResult.idToken)); + if (account.refreshToken !== refreshResult.refresh) { + account.refreshToken = refreshResult.refresh; + changed = true; + } + if (account.accessToken !== refreshResult.access) { + account.accessToken = refreshResult.access; + changed = true; + } + if (account.expiresAt !== refreshResult.expires) { + account.expiresAt = refreshResult.expires; + changed = true; + } + if (nextEmail && nextEmail !== account.email) { + account.email = nextEmail; + changed = true; + } + if (tokenAccountId && tokenAccountId !== account.accountId) { + account.accountId = tokenAccountId; + account.accountIdSource = "token"; + changed = true; + } + syncAccessToken = refreshResult.access; + syncRefreshToken = refreshResult.refresh; + syncExpiresAt = refreshResult.expires; + syncIdToken = refreshResult.idToken; + } + } + + if (changed) { + await saveAccounts(storage); + } + + return setCodexCliActiveSelection({ + accountId: account.accountId, + email: account.email, + accessToken: syncAccessToken, + refreshToken: syncRefreshToken, + expiresAt: syncExpiresAt, + ...(syncIdToken ? { idToken: syncIdToken } : {}), + }); +} + export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { const startupDisplaySettings = await loadDashboardDisplaySettings(); applyUiThemeFromDashboardSettings(startupDisplaySettings); diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index b6769030..99cbdae4 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -517,7 +517,7 @@ const BACKEND_CATEGORY_OPTIONS: BackendCategoryOption[] = [ type DashboardSettingKey = keyof DashboardDisplaySettings; -const RETRYABLE_SETTINGS_WRITE_CODES = new Set(["EBUSY", "EPERM", "EAGAIN"]); +const RETRYABLE_SETTINGS_WRITE_CODES = new Set(["EBUSY", "EPERM", "EAGAIN", "ENOTEMPTY", "EACCES"]); const SETTINGS_WRITE_MAX_ATTEMPTS = 4; const SETTINGS_WRITE_BASE_DELAY_MS = 20; const SETTINGS_WRITE_MAX_DELAY_MS = 30_000; @@ -1061,6 +1061,46 @@ function formatMenuQuotaTtl(ttlMs: number): string { return `${ttlMs}ms`; } +function clampBackendNumberForTests(settingKey: string, value: number): number { + const option = BACKEND_NUMBER_OPTION_BY_KEY.get(settingKey as BackendNumberSettingKey); + if (!option) { + throw new Error(`Unknown backend numeric setting key: ${settingKey}`); + } + return clampBackendNumber(option, value); +} + +async function withQueuedRetryForTests( + pathKey: string, + task: () => Promise, +): Promise { + return withQueuedRetry(pathKey, task); +} + +async function persistDashboardSettingsSelectionForTests( + selected: DashboardDisplaySettings, + keys: ReadonlyArray, + scope: string, +): Promise { + return persistDashboardSettingsSelection(selected, keys as readonly DashboardSettingKey[], scope); +} + +async function persistBackendConfigSelectionForTests( + selected: PluginConfig, + scope: string, +): Promise { + return persistBackendConfigSelection(selected, scope); +} + +const __testOnly = { + clampBackendNumber: clampBackendNumberForTests, + formatMenuLayoutMode, + cloneDashboardSettings, + withQueuedRetry: withQueuedRetryForTests, + persistDashboardSettingsSelection: persistDashboardSettingsSelectionForTests, + persistBackendConfigSelection: persistBackendConfigSelectionForTests, +}; + +/* c8 ignore start - interactive prompt flows are covered by integration tests */ async function promptDashboardDisplaySettings( initial: DashboardDisplaySettings, ): Promise { @@ -2054,6 +2094,8 @@ async function promptSettingsHub( }); } +/* c8 ignore stop */ + async function configureUnifiedSettings( initialSettings?: DashboardDisplaySettings, ): Promise { @@ -2096,5 +2138,5 @@ async function configureUnifiedSettings( } } -export { configureUnifiedSettings, applyUiThemeFromDashboardSettings, resolveMenuLayoutMode }; +export { configureUnifiedSettings, applyUiThemeFromDashboardSettings, resolveMenuLayoutMode, __testOnly }; diff --git a/lib/oauth-success.html b/lib/oauth-success.html index 7a9dbd4a..72a8d667 100644 --- a/lib/oauth-success.html +++ b/lib/oauth-success.html @@ -3,336 +3,268 @@ - OpenAI Codex Sign-in Complete + OpenAI Codex Callback Received - - - -
-
-
- - - +
+

OpenAI Codex

+ +

Callback received

+

Return to your terminal to complete sign-in.

+
+ Status + Processing +
+
    +
  • + Authorization code + received +
  • +
  • + Token exchange + running in terminal +
  • +
  • + Credentials + persisting after terminal completes +
  • +
  • + Session + activating in terminal +
  • +
+
+
+ Callback + + localhost:1455 +
+
+ Tip + Close this tab when you are done.
-
OpenAI Codex - Callback Received
-
- -
-
-

OpenAI Codex

-

Sign-in Complete

-

Account connected. Return to terminal.

- -
Connected
- -
    -
  • Authorization code received
  • -
  • Token exchange complete
  • -
  • Credentials saved to local Codex auth storage
  • -
  • Session ready
  • -
-
- -
- -
- Status: Connected - Back to terminal to continue. -
+

Next: return to the terminal window to continue.

+
Back to terminal to continue.
diff --git a/lib/parallel-probe.ts b/lib/parallel-probe.ts index a939ec31..7500105b 100644 --- a/lib/parallel-probe.ts +++ b/lib/parallel-probe.ts @@ -27,20 +27,74 @@ export interface ParallelProbeOptions { timeoutMs: number; } +export interface GetTopCandidatesParams { + accountManager: AccountManager; + modelFamily: ModelFamily; + model: string | null; + maxCandidates: number; +} + /** * Get top N candidates ranked by hybrid score WITHOUT mutating AccountManager state. * Uses getAccountsSnapshot() and ranks by health + tokens + freshness. */ +export function getTopCandidates( + params: GetTopCandidatesParams, +): ManagedAccount[]; export function getTopCandidates( accountManager: AccountManager, modelFamily: ModelFamily, model: string | null, maxCandidates: number, +): ManagedAccount[]; +export function getTopCandidates( + accountManagerOrParams: AccountManager | GetTopCandidatesParams, + modelFamily?: ModelFamily, + model?: string | null, + maxCandidates?: number, ): ManagedAccount[] { - const accounts = accountManager.getAccountsSnapshot(); + const useNamedParams = typeof modelFamily === "undefined"; + let resolvedAccountManager: AccountManager; + let resolvedModelFamily: ModelFamily | undefined; + let resolvedModel: string | null | undefined; + let resolvedMaxCandidates: number | undefined; + + if (useNamedParams) { + const namedParams = accountManagerOrParams as GetTopCandidatesParams; + resolvedAccountManager = namedParams.accountManager; + resolvedModelFamily = namedParams.modelFamily; + resolvedModel = namedParams.model; + resolvedMaxCandidates = namedParams.maxCandidates; + } else { + resolvedAccountManager = accountManagerOrParams as AccountManager; + resolvedModelFamily = modelFamily; + resolvedModel = model; + resolvedMaxCandidates = maxCandidates; + } + + if ( + !resolvedAccountManager || + typeof resolvedAccountManager.getAccountsSnapshot !== "function" + ) { + throw new TypeError("getTopCandidates requires accountManager"); + } + if (!resolvedModelFamily) { + throw new TypeError("getTopCandidates requires modelFamily"); + } + if ( + typeof resolvedMaxCandidates !== "number" || + !Number.isInteger(resolvedMaxCandidates) || + resolvedMaxCandidates <= 0 + ) { + throw new TypeError("getTopCandidates requires maxCandidates to be a positive integer"); + } + const normalizedModelFamily = resolvedModelFamily; + const normalizedMaxCandidates = resolvedMaxCandidates; + + const accounts = resolvedAccountManager.getAccountsSnapshot(); if (accounts.length === 0) return []; - const quotaKey = model ? `${modelFamily}:${model}` : modelFamily; + const quotaKey = resolvedModel ? `${normalizedModelFamily}:${resolvedModel}` : normalizedModelFamily; const healthTracker = getHealthTracker(); const tokenTracker = getTokenTracker(); @@ -48,7 +102,7 @@ export function getTopCandidates( for (const account of accounts) { clearExpiredRateLimits(account); - const isRateLimited = isRateLimitedForFamily(account, modelFamily, model); + const isRateLimited = isRateLimitedForFamily(account, normalizedModelFamily, resolvedModel); const isCoolingDown = account.coolingDownUntil !== undefined && account.coolingDownUntil > Date.now(); const isAvailable = !isRateLimited && !isCoolingDown; @@ -74,7 +128,7 @@ export function getTopCandidates( scored.sort((a, b) => b.score - a.score); - return scored.slice(0, maxCandidates).map((s) => s.account); + return scored.slice(0, normalizedMaxCandidates).map((s) => s.account); } /** diff --git a/lib/refresh-queue.ts b/lib/refresh-queue.ts index be81f1f3..4ada4f90 100644 --- a/lib/refresh-queue.ts +++ b/lib/refresh-queue.ts @@ -12,6 +12,7 @@ import { refreshAccessToken } from "./auth/auth.js"; import type { TokenResult } from "./types.js"; import { createLogger } from "./logger.js"; import { RefreshLeaseCoordinator } from "./refresh-lease.js"; +import { isAbortError } from "./utils.js"; const log = createLogger("refresh-queue"); @@ -21,6 +22,9 @@ const log = createLogger("refresh-queue"); interface RefreshEntry { promise: Promise; startedAt: number; + stage: "acquire" | "refresh"; + generation: number; + staleWarningLogged?: boolean; } /** @@ -57,6 +61,7 @@ interface RefreshEntry { export class RefreshQueue { private pending: Map = new Map(); private readonly leaseCoordinator: RefreshLeaseCoordinator; + private nextGeneration = 0; /** * Maps old refresh tokens to new tokens after rotation. @@ -66,9 +71,7 @@ export class RefreshQueue { private tokenRotationMap: Map = new Map(); /** - * Maximum time to keep a refresh entry in the queue (prevents memory leaks - * from stuck requests). After this timeout, the entry is removed and new - * callers will trigger a fresh refresh. + * Age threshold for stale pending refresh operations. */ private readonly maxEntryAgeMs: number; @@ -124,6 +127,26 @@ export class RefreshQueue { // Start a new refresh immediately so local state reflects "in-flight" // without waiting on cross-process lease checks. const startedAt = Date.now(); + const generation = ++this.nextGeneration; + const markStage = (stage: "acquire" | "refresh") => { + const entry = this.pending.get(refreshToken); + if (!entry || entry.generation !== generation) return; + entry.stage = stage; + entry.startedAt = Date.now(); + entry.staleWarningLogged = false; + }; + const getSupersedingPromise = (): Promise | undefined => { + const current = this.pending.get(refreshToken); + if (!current || current.generation === generation) { + return undefined; + } + log.info("Refresh generation superseded; joining newer in-flight refresh", { + tokenSuffix: refreshToken.slice(-6), + staleGeneration: generation, + activeGeneration: current.generation, + }); + return current.promise; + }; const promise = (async (): Promise => { let lease: Awaited>; try { @@ -133,6 +156,11 @@ export class RefreshQueue { tokenSuffix: refreshToken.slice(-6), error: (error as Error)?.message ?? String(error), }); + const supersedingPromise = getSupersedingPromise(); + if (supersedingPromise) { + return supersedingPromise; + } + markStage("refresh"); return this.executeRefreshWithRotationTracking(refreshToken); } if (lease.role === "follower" && lease.result) { @@ -143,6 +171,11 @@ export class RefreshQueue { } try { + const supersedingPromise = getSupersedingPromise(); + if (supersedingPromise) { + return supersedingPromise; + } + markStage("refresh"); const result = await this.executeRefreshWithRotationTracking(refreshToken); try { await lease.release(result); @@ -164,13 +197,21 @@ export class RefreshQueue { } } })(); - this.pending.set(refreshToken, { promise, startedAt }); + this.pending.set(refreshToken, { + promise, + startedAt, + stage: "acquire", + generation, + }); try { return await promise; } finally { - this.pending.delete(refreshToken); - this.cleanupRotationMapping(refreshToken); + const entry = this.pending.get(refreshToken); + if (!entry || entry.generation === generation) { + this.pending.delete(refreshToken); + this.cleanupRotationMapping(refreshToken); + } } } @@ -212,9 +253,25 @@ export class RefreshQueue { private async executeRefresh(refreshToken: string): Promise { const startTime = Date.now(); log.info("Starting token refresh", { tokenSuffix: refreshToken.slice(-6) }); + const timeoutMs = Math.max(1_000, this.maxEntryAgeMs); + const timeoutController = new AbortController(); + let timeoutId: ReturnType | undefined; try { - const result = await refreshAccessToken(refreshToken); + const timeoutErrorMessage = `Refresh timeout after ${timeoutMs}ms`; + const timeoutPromise = new Promise((_resolve, reject) => { + timeoutId = setTimeout(() => { + const timeoutError = new Error(timeoutErrorMessage) as Error & { code?: string }; + timeoutError.name = "AbortError"; + timeoutError.code = "ABORT_ERR"; + timeoutController.abort(timeoutError); + reject(timeoutError); + }, timeoutMs); + }); + const refreshPromise = refreshAccessToken(refreshToken, { + signal: timeoutController.signal, + }); + const result = await Promise.race([refreshPromise, timeoutPromise]); const duration = Date.now() - startTime; if (result.type === "success") { @@ -233,6 +290,18 @@ export class RefreshQueue { return result; } catch (error) { const duration = Date.now() - startTime; + if (isAbortError(error)) { + log.warn("Token refresh aborted", { + tokenSuffix: refreshToken.slice(-6), + error: (error as Error)?.message ?? String(error), + durationMs: duration, + }); + return { + type: "failed", + reason: "unknown", + message: (error as Error)?.message ?? "Refresh aborted", + }; + } log.error("Token refresh threw exception", { tokenSuffix: refreshToken.slice(-6), error: (error as Error)?.message ?? String(error), @@ -244,31 +313,38 @@ export class RefreshQueue { reason: "network_error", message: (error as Error)?.message ?? "Unknown error during refresh", }; + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } } } /** - * Remove stale entries that have been pending too long. - * This prevents memory leaks from stuck or abandoned refresh operations. + * Cleanup stale entries that have been pending too long. + * Acquire-stage entries are evicted to prevent deadlocked refresh lanes. */ private cleanup(): void { const now = Date.now(); - const staleTokens: string[] = []; - for (const [token, entry] of this.pending.entries()) { - if (now - entry.startedAt > this.maxEntryAgeMs) { - staleTokens.push(token); + const ageMs = now - entry.startedAt; + if (ageMs <= this.maxEntryAgeMs) continue; + if (entry.stage === "acquire") { + log.warn("Evicting stale refresh entry during lease acquire stage", { + tokenSuffix: token.slice(-6), + ageMs, + }); + this.pending.delete(token); + this.cleanupRotationMapping(token); + continue; + } + if (!entry.staleWarningLogged) { + log.warn("Refresh entry exceeded stale warning threshold", { + tokenSuffix: token.slice(-6), + ageMs, + }); + entry.staleWarningLogged = true; } - } - - for (const token of staleTokens) { - // istanbul ignore next -- defensive: token always exists in pending at this point (not yet deleted) - const ageMs = now - (this.pending.get(token)?.startedAt ?? now); - log.warn("Removing stale refresh entry", { - tokenSuffix: token.slice(-6), - ageMs, - }); - this.pending.delete(token); } } diff --git a/lib/request/fetch-helpers.ts b/lib/request/fetch-helpers.ts index 69ea572b..3ed9967a 100644 --- a/lib/request/fetch-helpers.ts +++ b/lib/request/fetch-helpers.ts @@ -57,6 +57,8 @@ const CHATGPT_CODEX_UNSUPPORTED_MODEL_PATTERN = /model is not supported when using codex with a chatgpt account/i; const NORMALIZED_UNSUPPORTED_MODEL_PATTERN = /the model ['"]([^'"]+)['"] is not currently available for this chatgpt account/i; +const MAX_RETRY_DELAY_MS = 5 * 60 * 1000; +const CREATE_CODEX_HEADERS_PARAM_KEYS = new Set(["init", "accountId", "accessToken", "opts"]); export const DEFAULT_UNSUPPORTED_CODEX_FALLBACK_CHAIN: Record = { "gpt-5.3-codex-spark": ["gpt-5-codex", "gpt-5.3-codex", "gpt-5.2-codex"], @@ -306,6 +308,24 @@ export interface ErrorDiagnostics { httpStatus?: number; } +export interface CreateCodexHeadersOptions { + model?: string; + promptCacheKey?: string; +} + +export interface CreateCodexHeadersParams { + init?: RequestInit; + accountId: string; + accessToken: string; + opts?: CreateCodexHeadersOptions; +} + +function isCreateCodexHeadersNamedParams(value: unknown): value is CreateCodexHeadersParams { + if (!isRecord(value)) return false; + if (typeof value.accountId !== "string" || typeof value.accessToken !== "string") return false; + return Object.keys(value).every((key) => CREATE_CODEX_HEADERS_PARAM_KEYS.has(key)); +} + /** * Determines if the current auth token needs to be refreshed * @param auth - Current authentication state @@ -502,20 +522,45 @@ export async function transformRequestForCodex( * @param accessToken - OAuth access token * @returns Headers object with all required Codex headers */ +export function createCodexHeaders( + params: CreateCodexHeadersParams, +): Headers; export function createCodexHeaders( init: RequestInit | undefined, accountId: string, accessToken: string, - opts?: { model?: string; promptCacheKey?: string }, + opts?: CreateCodexHeadersOptions, +): Headers; +export function createCodexHeaders( + initOrParams: RequestInit | undefined | CreateCodexHeadersParams, + accountId?: string, + accessToken?: string, + opts?: CreateCodexHeadersOptions, ): Headers { - const headers = new Headers(init?.headers ?? {}); + const useNamedParams = + typeof accountId === "undefined" && + typeof accessToken === "undefined" && + isCreateCodexHeadersNamedParams(initOrParams); + const namedParams = useNamedParams + ? (initOrParams as CreateCodexHeadersParams) + : null; + const resolvedInit = useNamedParams + ? namedParams?.init + : (initOrParams as RequestInit | undefined); + const resolvedAccountId = useNamedParams ? namedParams?.accountId : accountId; + const resolvedAccessToken = useNamedParams ? namedParams?.accessToken : accessToken; + const resolvedOpts = useNamedParams ? namedParams?.opts : opts; + if (!resolvedAccountId || !resolvedAccessToken) { + throw new TypeError("createCodexHeaders requires accountId and accessToken"); + } + const headers = new Headers(resolvedInit?.headers ?? {}); headers.delete("x-api-key"); // Remove any existing API key - headers.set("Authorization", `Bearer ${accessToken}`); - headers.set(OPENAI_HEADERS.ACCOUNT_ID, accountId); + headers.set("Authorization", `Bearer ${resolvedAccessToken}`); + headers.set(OPENAI_HEADERS.ACCOUNT_ID, resolvedAccountId); headers.set(OPENAI_HEADERS.BETA, OPENAI_HEADER_VALUES.BETA_RESPONSES); headers.set(OPENAI_HEADERS.ORIGINATOR, OPENAI_HEADER_VALUES.ORIGINATOR_CODEX); - const cacheKey = opts?.promptCacheKey; + const cacheKey = resolvedOpts?.promptCacheKey; if (cacheKey) { headers.set(OPENAI_HEADERS.CONVERSATION_ID, cacheKey); headers.set(OPENAI_HEADERS.SESSION_ID, cacheKey); @@ -691,15 +736,21 @@ interface RateLimitErrorBody { function parseRateLimitBody( body: string, -): { code?: string; resetsAt?: number; retryAfterMs?: number } | undefined { +): { + code?: string; + resetsAt?: number; + retryAfterMs?: number; + retryAfterSeconds?: number; +} | undefined { if (!body) return undefined; try { const parsed = JSON.parse(body) as RateLimitErrorBody; const error = parsed?.error ?? {}; const code = (error.code ?? error.type ?? "").toString(); const resetsAt = toNumber(error.resets_at ?? error.reset_at); - const retryAfterMs = toNumber(error.retry_after_ms ?? error.retry_after); - return { code, resetsAt, retryAfterMs }; + const retryAfterMs = toNumber(error.retry_after_ms); + const retryAfterSeconds = toNumber(error.retry_after); + return { code, resetsAt, retryAfterMs, retryAfterSeconds }; } catch { return undefined; } @@ -839,12 +890,18 @@ function ensureJsonErrorResponse(response: Response, payload: ErrorPayload): Res } function parseRetryAfterMs( - response: Response, - parsedBody?: { resetsAt?: number; retryAfterMs?: number }, + response: Response, + parsedBody?: { resetsAt?: number; retryAfterMs?: number; retryAfterSeconds?: number }, ): number | null { - if (parsedBody?.retryAfterMs !== undefined) { - return normalizeRetryAfter(parsedBody.retryAfterMs); - } + if (parsedBody?.retryAfterMs !== undefined) { + const normalized = normalizeRetryAfterMs(parsedBody.retryAfterMs); + if (normalized !== null) return normalized; + } + + if (parsedBody?.retryAfterSeconds !== undefined) { + const normalized = normalizeRetryAfterSeconds(parsedBody.retryAfterSeconds); + if (normalized !== null) return normalized; + } const retryAfterMsHeader = response.headers.get("retry-after-ms"); if (retryAfterMsHeader) { @@ -897,16 +954,18 @@ function parseRetryAfterMs( return null; } -function normalizeRetryAfter(value: number): number { - if (!Number.isFinite(value)) return 60000; - let ms: number; - if (value > 0 && value < 1000) { - ms = Math.floor(value * 1000); - } else { - ms = Math.floor(value); - } - const MAX_RETRY_DELAY_MS = 5 * 60 * 1000; - return Math.min(ms, MAX_RETRY_DELAY_MS); +function normalizeRetryAfterMs(value: number): number | null { + if (!Number.isFinite(value)) return null; + const ms = Math.floor(value); + if (ms <= 0) return null; + return Math.min(ms, MAX_RETRY_DELAY_MS); +} + +function normalizeRetryAfterSeconds(value: number): number | null { + if (!Number.isFinite(value)) return null; + const ms = Math.floor(value * 1000); + if (ms <= 0) return null; + return Math.min(ms, MAX_RETRY_DELAY_MS); } function toNumber(value: unknown): number | undefined { diff --git a/lib/request/helpers/tool-utils.ts b/lib/request/helpers/tool-utils.ts index 14212e44..7c14ec78 100644 --- a/lib/request/helpers/tool-utils.ts +++ b/lib/request/helpers/tool-utils.ts @@ -1,3 +1,5 @@ +import { isRecord } from "../../utils.js"; + export interface ToolFunction { name: string; description?: string; @@ -14,6 +16,10 @@ export interface Tool { function: ToolFunction; } +function cloneRecord(value: Record): Record { + return JSON.parse(JSON.stringify(value)) as Record; +} + /** * Cleans up tool definitions to ensure strict JSON Schema compliance. * @@ -31,17 +37,34 @@ export function cleanupToolDefinitions(tools: unknown): unknown { if (!Array.isArray(tools)) return tools; return tools.map((tool) => { - if (tool?.type !== "function" || !tool.function) { + if (!isRecord(tool) || tool.type !== "function") { + return tool; + } + const functionDef = tool.function; + if (!isRecord(functionDef)) { + return tool; + } + const parameters = functionDef.parameters; + if (!isRecord(parameters)) { return tool; } - // Clone to avoid mutating original - const cleanedTool = JSON.parse(JSON.stringify(tool)); - if (cleanedTool.function.parameters) { - cleanupSchema(cleanedTool.function.parameters); + // Clone only the schema tree we mutate to avoid heavy deep cloning of entire tools. + let cleanedParameters: Record; + try { + cleanedParameters = cloneRecord(parameters); + } catch { + return tool; } + cleanupSchema(cleanedParameters); - return cleanedTool; + return { + ...tool, + function: { + ...functionDef, + parameters: cleanedParameters, + }, + }; }); } @@ -51,6 +74,15 @@ export function cleanupToolDefinitions(tools: unknown): unknown { function cleanupSchema(schema: Record): void { if (!schema || typeof schema !== "object") return; + if (schema.properties && typeof schema.properties === "object") { + const properties = schema.properties as Record; + for (const key of Object.keys(properties)) { + if (properties[key] === undefined) { + delete properties[key]; + } + } + } + // 1. Flatten Unions (anyOf -> enum) if (Array.isArray(schema.anyOf)) { const anyOf = schema.anyOf as Record[]; diff --git a/lib/request/rate-limit-backoff.ts b/lib/request/rate-limit-backoff.ts index 4b617fd1..9b528b6e 100644 --- a/lib/request/rate-limit-backoff.ts +++ b/lib/request/rate-limit-backoff.ts @@ -113,17 +113,63 @@ export function calculateBackoffMs( return Math.min(Math.floor(exponentialDelay * multiplier), MAX_BACKOFF_MS); } +export interface RateLimitBackoffWithReasonParams { + accountIndex: number; + quotaKey: string; + serverRetryAfterMs: number | null | undefined; + reason?: RateLimitReason; +} + +export function getRateLimitBackoffWithReason( + params: RateLimitBackoffWithReasonParams, +): RateLimitBackoffResult; export function getRateLimitBackoffWithReason( accountIndex: number, quotaKey: string, serverRetryAfterMs: number | null | undefined, + reason?: RateLimitReason, +): RateLimitBackoffResult; +export function getRateLimitBackoffWithReason( + accountIndexOrParams: number | RateLimitBackoffWithReasonParams, + quotaKey?: string, + serverRetryAfterMs?: number | null | undefined, reason: RateLimitReason = "unknown", ): RateLimitBackoffResult { - const result = getRateLimitBackoff(accountIndex, quotaKey, serverRetryAfterMs); - const adjustedDelay = calculateBackoffMs(result.delayMs, result.attempt, reason); + const useNamedParams = typeof accountIndexOrParams !== "number"; + const resolvedAccountIndex = useNamedParams + ? accountIndexOrParams.accountIndex + : accountIndexOrParams; + const resolvedQuotaKey = useNamedParams + ? accountIndexOrParams.quotaKey + : quotaKey; + const resolvedServerRetryAfterMs = useNamedParams + ? accountIndexOrParams.serverRetryAfterMs + : serverRetryAfterMs; + const resolvedReason = useNamedParams + ? (accountIndexOrParams.reason ?? "unknown") + : reason; + if (!Number.isInteger(resolvedAccountIndex) || resolvedAccountIndex < 0) { + throw new TypeError( + "getRateLimitBackoffWithReason requires a non-negative integer accountIndex", + ); + } + if (typeof resolvedQuotaKey !== "string" || resolvedQuotaKey.trim().length === 0) { + throw new TypeError("getRateLimitBackoffWithReason requires a non-empty quotaKey"); + } + const normalizedQuotaKey = resolvedQuotaKey.trim(); + const result = getRateLimitBackoff( + resolvedAccountIndex, + normalizedQuotaKey, + resolvedServerRetryAfterMs, + ); + const adjustedDelay = calculateBackoffMs( + result.delayMs, + result.attempt, + resolvedReason, + ); return { ...result, delayMs: adjustedDelay, - reason, + reason: resolvedReason, }; } diff --git a/lib/request/request-transformer.ts b/lib/request/request-transformer.ts index f668b5c4..33a4715e 100644 --- a/lib/request/request-transformer.ts +++ b/lib/request/request-transformer.ts @@ -21,6 +21,16 @@ type CollaborationMode = "plan" | "default" | "unknown"; type FastSessionStrategy = "hybrid" | "always"; type SupportedReasoningSummary = "auto" | "concise" | "detailed"; +export interface TransformRequestBodyParams { + body: RequestBody; + codexInstructions: string; + userConfig?: UserConfig; + codexMode?: boolean; + fastSession?: boolean; + fastSessionStrategy?: FastSessionStrategy; + fastSessionMaxInputItems?: number; +} + const PLAN_MODE_ONLY_TOOLS = new Set(["request_user_input"]); export { @@ -543,24 +553,25 @@ export function filterInput( input: InputItem[] | undefined, ): InputItem[] | undefined { if (!Array.isArray(input)) return input; - - return input - .filter((item) => { - // Remove AI SDK constructs not supported by Codex API - if (item.type === "item_reference") { - return false; // AI SDK only - references server state - } - return true; // Keep all other items - }) - .map((item) => { - // Strip IDs from all items (Codex API stateless mode) - if (item.id) { - const { id: _omit, ...itemWithoutId } = item; - void _omit; - return itemWithoutId as InputItem; - } - return item; - }); + const filtered: InputItem[] = []; + for (const item of input) { + if (!item || typeof item !== "object") { + continue; + } + // Remove AI SDK constructs not supported by Codex API. + if (item.type === "item_reference") { + continue; + } + // Strip IDs from all items (Codex API stateless mode). + if ("id" in item) { + const { id: _omit, ...itemWithoutId } = item; + void _omit; + filtered.push(itemWithoutId as InputItem); + continue; + } + filtered.push(item); + } + return filtered; } /** @@ -818,28 +829,80 @@ export function addToolRemapMessage( * @param fastSession - Force low-latency output settings for faster responses * @returns Transformed request body */ +export async function transformRequestBody( + params: TransformRequestBodyParams, +): Promise; export async function transformRequestBody( body: RequestBody, codexInstructions: string, + userConfig?: UserConfig, + codexMode?: boolean, + fastSession?: boolean, + fastSessionStrategy?: FastSessionStrategy, + fastSessionMaxInputItems?: number, +): Promise; +export async function transformRequestBody( + bodyOrParams: RequestBody | TransformRequestBodyParams, + codexInstructions?: string, userConfig: UserConfig = { global: {}, models: {} }, codexMode = true, fastSession = false, fastSessionStrategy: FastSessionStrategy = "hybrid", fastSessionMaxInputItems = 30, ): Promise { + const useNamedParams = + typeof codexInstructions === "undefined" && + typeof bodyOrParams === "object" && + bodyOrParams !== null && + "body" in bodyOrParams && + "codexInstructions" in bodyOrParams; + let body: RequestBody; + let resolvedCodexInstructions: string | undefined; + let resolvedUserConfig: UserConfig; + let resolvedCodexMode: boolean; + let resolvedFastSession: boolean; + let resolvedFastSessionStrategy: FastSessionStrategy; + let resolvedFastSessionMaxInputItems: number; + + if (useNamedParams) { + const namedParams = bodyOrParams as TransformRequestBodyParams; + body = namedParams.body; + resolvedCodexInstructions = namedParams.codexInstructions; + resolvedUserConfig = namedParams.userConfig ?? { global: {}, models: {} }; + resolvedCodexMode = namedParams.codexMode ?? true; + resolvedFastSession = namedParams.fastSession ?? false; + resolvedFastSessionStrategy = namedParams.fastSessionStrategy ?? "hybrid"; + resolvedFastSessionMaxInputItems = namedParams.fastSessionMaxInputItems ?? 30; + } else { + body = bodyOrParams as RequestBody; + resolvedCodexInstructions = codexInstructions; + resolvedUserConfig = userConfig; + resolvedCodexMode = codexMode; + resolvedFastSession = fastSession; + resolvedFastSessionStrategy = fastSessionStrategy; + resolvedFastSessionMaxInputItems = fastSessionMaxInputItems; + } + + if (!body || typeof body !== "object") { + throw new TypeError("transformRequestBody requires body"); + } + if (typeof resolvedCodexInstructions !== "string") { + throw new TypeError("transformRequestBody requires codexInstructions"); + } + const originalModel = body.model; const normalizedModel = normalizeModel(body.model); // Get model-specific configuration using ORIGINAL model name (config key) // This allows per-model options like "gpt-5-codex-low" to work correctly const lookupModel = originalModel || normalizedModel; - const modelConfig = getModelConfig(lookupModel, userConfig); + const modelConfig = getModelConfig(lookupModel, resolvedUserConfig); // Debug: Log which config was resolved logDebug( - `Model config lookup: "${lookupModel}" → normalized to "${normalizedModel}" for API`, + `Model config lookup: "${lookupModel}" → normalized to "${normalizedModel}" for API`, { - hasModelSpecificConfig: !!userConfig.models?.[lookupModel], + hasModelSpecificConfig: !!resolvedUserConfig.models?.[lookupModel], resolvedConfig: modelConfig, }, ); @@ -853,9 +916,9 @@ export async function transformRequestBody( ? normalizedModel : lookupModel; const shouldApplyFastSessionTuning = - fastSession && - (fastSessionStrategy === "always" || - !isComplexFastSessionRequest(body, fastSessionMaxInputItems)); + resolvedFastSession && + (resolvedFastSessionStrategy === "always" || + !isComplexFastSessionRequest(body, resolvedFastSessionMaxInputItems)); const latestUserText = getLatestUserText(body.input); const isTrivialTurn = isTrivialLatestPrompt(latestUserText ?? ""); const shouldDisableToolsForTrivialTurn = @@ -884,8 +947,8 @@ export async function transformRequestBody( } body.instructions = shouldApplyFastSessionTuning - ? compactInstructionsForFastSession(codexInstructions, isTrivialTurn) - : codexInstructions; + ? compactInstructionsForFastSession(resolvedCodexInstructions, isTrivialTurn) + : resolvedCodexInstructions; // Prompt caching relies on the host providing a stable prompt_cache_key // Host passes its session identifier. We no longer synthesize one here. @@ -896,9 +959,9 @@ export async function transformRequestBody( if (shouldApplyFastSessionTuning) { inputItems = - trimInputForFastSession(inputItems, fastSessionMaxInputItems, { - preferLatestUserOnly: shouldPreferLatestUserOnly, - }) ?? inputItems; + trimInputForFastSession(inputItems, resolvedFastSessionMaxInputItems, { + preferLatestUserOnly: shouldPreferLatestUserOnly, + }) ?? inputItems; } // Debug: Log original input message IDs before filtering @@ -929,7 +992,7 @@ export async function transformRequestBody( logDebug(`Successfully removed all ${originalIds.length} message IDs`); } - if (codexMode) { + if (resolvedCodexMode) { // CODEX_MODE: Remove host system prompt, add bridge prompt body.input = await filterHostSystemPrompts(body.input); body.input = addCodexBridgeMessage(body.input, !!body.tools); diff --git a/lib/rotation.ts b/lib/rotation.ts index 652a9173..5242cd95 100644 --- a/lib/rotation.ts +++ b/lib/rotation.ts @@ -300,6 +300,18 @@ export interface HybridSelectionOptions { scoreBoostByAccount?: Record; } +/** + * Named-parameter alternative for selectHybridAccount to avoid brittle positional arguments. + */ +export interface SelectHybridAccountParams { + accounts: AccountWithMetrics[]; + healthTracker: HealthScoreTracker; + tokenTracker: TokenBucketTracker; + quotaKey?: string; + config?: Partial; + options?: HybridSelectionOptions; +} + /** * Selects the best account from a set using a weighted hybrid score composed of health, token availability, and freshness. * @@ -315,22 +327,53 @@ export interface HybridSelectionOptions { * - Selection is deterministic given the same inputs except when `pidOffsetEnabled` is used to bias selection per-process. * - The function is purely in-memory and performs no filesystem operations (no Windows filesystem considerations). */ +export function selectHybridAccount( + params: SelectHybridAccountParams, +): AccountWithMetrics | null; export function selectHybridAccount( accounts: AccountWithMetrics[], healthTracker: HealthScoreTracker, tokenTracker: TokenBucketTracker, quotaKey?: string, + config?: Partial, + options?: HybridSelectionOptions, +): AccountWithMetrics | null; +export function selectHybridAccount( + accountsOrParams: AccountWithMetrics[] | SelectHybridAccountParams, + healthTracker?: HealthScoreTracker, + tokenTracker?: TokenBucketTracker, + quotaKey?: string, config: Partial = {}, options: HybridSelectionOptions = {}, ): AccountWithMetrics | null { - const cfg = { ...DEFAULT_HYBRID_SELECTION_CONFIG, ...config }; - const available = accounts.filter((a) => a.isAvailable); + const namedParams = + !Array.isArray(accountsOrParams) && + accountsOrParams !== null && + typeof accountsOrParams === "object" + ? accountsOrParams + : null; + const resolvedAccounts = namedParams ? namedParams.accounts : accountsOrParams; + const resolvedHealthTracker = namedParams ? namedParams.healthTracker : healthTracker; + const resolvedTokenTracker = namedParams ? namedParams.tokenTracker : tokenTracker; + const resolvedQuotaKey = namedParams ? namedParams.quotaKey : quotaKey; + const resolvedConfig = namedParams ? (namedParams.config ?? {}) : config; + const resolvedOptions = namedParams ? (namedParams.options ?? {}) : options; + + if (!Array.isArray(resolvedAccounts)) { + throw new TypeError("selectHybridAccount requires accounts to be an array"); + } + if (!resolvedHealthTracker || !resolvedTokenTracker) { + throw new TypeError("selectHybridAccount requires healthTracker and tokenTracker"); + } + + const cfg = { ...DEFAULT_HYBRID_SELECTION_CONFIG, ...resolvedConfig }; + const available = resolvedAccounts.filter((a) => a.isAvailable); if (available.length === 0) { - if (accounts.length === 0) return null; + if (resolvedAccounts.length === 0) return null; let leastRecentlyUsed: AccountWithMetrics | null = null; let oldestTime = Infinity; - for (const account of accounts) { + for (const account of resolvedAccounts) { if (account.lastUsed < oldestTime) { oldestTime = account.lastUsed; leastRecentlyUsed = account; @@ -347,16 +390,16 @@ export function selectHybridAccount( // PID offset: distribute account selection across parallel processes // Each process gets a small deterministic bonus based on its PID - const pidBonus = options.pidOffsetEnabled ? (process.pid % 100) * 0.01 : 0; + const pidBonus = resolvedOptions.pidOffsetEnabled ? (process.pid % 100) * 0.01 : 0; for (const account of available) { - const health = healthTracker.getScore(account.index, quotaKey); - const tokens = tokenTracker.getTokens(account.index, quotaKey); + const health = resolvedHealthTracker.getScore(account.index, resolvedQuotaKey); + const tokens = resolvedTokenTracker.getTokens(account.index, resolvedQuotaKey); const hoursSinceUsed = (now - account.lastUsed) / (1000 * 60 * 60); const capabilityBoost = - typeof options.scoreBoostByAccount?.[account.index] === "number" - ? options.scoreBoostByAccount[account.index] ?? 0 + typeof resolvedOptions.scoreBoostByAccount?.[account.index] === "number" + ? resolvedOptions.scoreBoostByAccount[account.index] ?? 0 : 0; const safeCapabilityBoost = Number.isFinite(capabilityBoost) ? capabilityBoost : 0; @@ -367,7 +410,7 @@ export function selectHybridAccount( safeCapabilityBoost; // PID-based offset distributes selection across parallel agents - if (options.pidOffsetEnabled) { + if (resolvedOptions.pidOffsetEnabled) { score += ((account.index * 0.131 + pidBonus) % 1) * cfg.freshnessWeight * 0.1; } @@ -378,8 +421,8 @@ export function selectHybridAccount( } if (bestAccount && available.length > 1) { - const health = healthTracker.getScore(bestAccount.index, quotaKey); - const tokens = tokenTracker.getTokens(bestAccount.index, quotaKey); + const health = resolvedHealthTracker.getScore(bestAccount.index, resolvedQuotaKey); + const tokens = resolvedTokenTracker.getTokens(bestAccount.index, resolvedQuotaKey); log.debug("Selected account", { index: bestAccount.index, health: Math.round(health), @@ -417,6 +460,13 @@ export function randomDelay(minMs: number, maxMs: number): number { return Math.floor(minMs + Math.random() * (maxMs - minMs)); } +export interface ExponentialBackoffOptions { + attempt: number; + baseMs?: number; + maxMs?: number; + jitterFactor?: number; +} + /** * Calculates exponential backoff with jitter. * @param attempt - Attempt number (1-based) @@ -425,14 +475,54 @@ export function randomDelay(minMs: number, maxMs: number): number { * @param jitterFactor - Jitter factor (0-1) * @returns Backoff delay with jitter */ +export function exponentialBackoff(options: ExponentialBackoffOptions): number; export function exponentialBackoff( attempt: number, + baseMs?: number, + maxMs?: number, + jitterFactor?: number, +): number; +export function exponentialBackoff( + attemptOrOptions: number | ExponentialBackoffOptions, baseMs: number = 1000, maxMs: number = 60000, jitterFactor: number = 0.1, ): number { - const delay = Math.min(baseMs * Math.pow(2, attempt - 1), maxMs); - return addJitter(delay, jitterFactor); + const useNamedParams = + typeof attemptOrOptions === "object" && attemptOrOptions !== null; + const normalizedAttempt = useNamedParams + ? (attemptOrOptions as ExponentialBackoffOptions).attempt + : attemptOrOptions; + const normalizedBaseMs = useNamedParams + ? ((attemptOrOptions as ExponentialBackoffOptions).baseMs ?? 1000) + : baseMs; + const normalizedMaxMs = useNamedParams + ? ((attemptOrOptions as ExponentialBackoffOptions).maxMs ?? 60000) + : maxMs; + const normalizedJitterFactor = useNamedParams + ? ((attemptOrOptions as ExponentialBackoffOptions).jitterFactor ?? 0.1) + : jitterFactor; + if (!Number.isInteger(normalizedAttempt) || normalizedAttempt < 1) { + throw new TypeError("exponentialBackoff requires attempt to be a positive integer"); + } + if (!Number.isFinite(normalizedBaseMs) || normalizedBaseMs < 0) { + throw new TypeError("exponentialBackoff requires baseMs to be a finite non-negative number"); + } + if (!Number.isFinite(normalizedMaxMs) || normalizedMaxMs < 0) { + throw new TypeError("exponentialBackoff requires maxMs to be a finite non-negative number"); + } + if ( + !Number.isFinite(normalizedJitterFactor) || + normalizedJitterFactor < 0 || + normalizedJitterFactor > 1 + ) { + throw new TypeError("exponentialBackoff requires jitterFactor to be between 0 and 1"); + } + const delay = Math.min( + normalizedBaseMs * Math.pow(2, normalizedAttempt - 1), + normalizedMaxMs, + ); + return addJitter(delay, normalizedJitterFactor); } // ============================================================================ diff --git a/lib/ui/copy.ts b/lib/ui/copy.ts index c3f64721..cbaa96d2 100644 --- a/lib/ui/copy.ts +++ b/lib/ui/copy.ts @@ -41,7 +41,7 @@ export const UI_COPY = { pastePrompt: "Paste callback URL or code here (Q to cancel):", browserOpened: "Browser opened.", browserOpenFail: "Could not open browser. Use this link:", - waitingCallback: "Waiting for login callback on 127.0.0.1:1455...", + waitingCallback: "Waiting for login callback on localhost:1455...", callbackMissed: "No callback received. Paste manually.", cancelled: "Sign-in cancelled.", cancelledBackToMenu: "Sign-in cancelled. Going back to menu.", diff --git a/lib/utils.ts b/lib/utils.ts index 469895ee..81e27cdd 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -12,6 +12,17 @@ export function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +/** + * Detects AbortError-compatible failures from fetch/abort-controller flows. + * @param error - Unknown thrown value + * @returns True when the error should be treated as an abort signal + */ +export function isAbortError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + const maybe = error as Error & { code?: string }; + return maybe.name === "AbortError" || maybe.code === "ABORT_ERR"; +} + /** * Returns the current timestamp in milliseconds. * Wrapper for Date.now() to enable testing with mocked time. diff --git a/package-lock.json b/package-lock.json index f37bca76..286a7505 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-multi-auth", - "version": "0.1.3", + "version": "0.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-multi-auth", - "version": "0.1.3", + "version": "0.1.4", "bundleDependencies": [ "@codex-ai/plugin" ], @@ -14,7 +14,7 @@ "dependencies": { "@codex-ai/plugin": "file:vendor/codex-ai-plugin", "@openauthjs/openauth": "^0.4.3", - "hono": "^4.12.0", + "hono": "4.12.3", "zod": "^4.3.6" }, "bin": { @@ -825,9 +825,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.56.0.tgz", - "integrity": "sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ "arm" ], @@ -839,9 +839,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.56.0.tgz", - "integrity": "sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "cpu": [ "arm64" ], @@ -853,9 +853,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.56.0.tgz", - "integrity": "sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], @@ -867,9 +867,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.56.0.tgz", - "integrity": "sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "cpu": [ "x64" ], @@ -881,9 +881,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.56.0.tgz", - "integrity": "sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "cpu": [ "arm64" ], @@ -895,9 +895,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.56.0.tgz", - "integrity": "sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "cpu": [ "x64" ], @@ -909,9 +909,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.56.0.tgz", - "integrity": "sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "cpu": [ "arm" ], @@ -923,9 +923,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.56.0.tgz", - "integrity": "sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "cpu": [ "arm" ], @@ -937,9 +937,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.56.0.tgz", - "integrity": "sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "cpu": [ "arm64" ], @@ -951,9 +951,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.56.0.tgz", - "integrity": "sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "cpu": [ "arm64" ], @@ -965,9 +965,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.56.0.tgz", - "integrity": "sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", "cpu": [ "loong64" ], @@ -979,9 +979,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.56.0.tgz", - "integrity": "sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", "cpu": [ "loong64" ], @@ -993,9 +993,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.56.0.tgz", - "integrity": "sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", "cpu": [ "ppc64" ], @@ -1007,9 +1007,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.56.0.tgz", - "integrity": "sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "cpu": [ "ppc64" ], @@ -1021,9 +1021,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.56.0.tgz", - "integrity": "sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "cpu": [ "riscv64" ], @@ -1035,9 +1035,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.56.0.tgz", - "integrity": "sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "cpu": [ "riscv64" ], @@ -1049,9 +1049,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.56.0.tgz", - "integrity": "sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "cpu": [ "s390x" ], @@ -1063,9 +1063,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.56.0.tgz", - "integrity": "sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "cpu": [ "x64" ], @@ -1077,9 +1077,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.56.0.tgz", - "integrity": "sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "cpu": [ "x64" ], @@ -1091,9 +1091,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.56.0.tgz", - "integrity": "sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", "cpu": [ "x64" ], @@ -1105,9 +1105,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.56.0.tgz", - "integrity": "sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "cpu": [ "arm64" ], @@ -1119,9 +1119,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.56.0.tgz", - "integrity": "sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "cpu": [ "arm64" ], @@ -1133,9 +1133,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.56.0.tgz", - "integrity": "sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "cpu": [ "ia32" ], @@ -1147,9 +1147,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.56.0.tgz", - "integrity": "sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", "cpu": [ "x64" ], @@ -1161,9 +1161,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.56.0.tgz", - "integrity": "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ "x64" ], @@ -1425,13 +1425,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -2375,9 +2375,9 @@ } }, "node_modules/hono": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.0.tgz", - "integrity": "sha512-NekXntS5M94pUfiVZ8oXXK/kkri+5WpX2/Ik+LVsl+uvw+soj4roXIsPqO+XsWrAw20mOzaXOZf3Q7PfB9A/IA==", + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.3.tgz", + "integrity": "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -2727,9 +2727,9 @@ } }, "node_modules/minimatch": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", - "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -3026,9 +3026,9 @@ "license": "MIT" }, "node_modules/rollup": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.56.0.tgz", - "integrity": "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", "dependencies": { @@ -3042,31 +3042,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.56.0", - "@rollup/rollup-android-arm64": "4.56.0", - "@rollup/rollup-darwin-arm64": "4.56.0", - "@rollup/rollup-darwin-x64": "4.56.0", - "@rollup/rollup-freebsd-arm64": "4.56.0", - "@rollup/rollup-freebsd-x64": "4.56.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.56.0", - "@rollup/rollup-linux-arm-musleabihf": "4.56.0", - "@rollup/rollup-linux-arm64-gnu": "4.56.0", - "@rollup/rollup-linux-arm64-musl": "4.56.0", - "@rollup/rollup-linux-loong64-gnu": "4.56.0", - "@rollup/rollup-linux-loong64-musl": "4.56.0", - "@rollup/rollup-linux-ppc64-gnu": "4.56.0", - "@rollup/rollup-linux-ppc64-musl": "4.56.0", - "@rollup/rollup-linux-riscv64-gnu": "4.56.0", - "@rollup/rollup-linux-riscv64-musl": "4.56.0", - "@rollup/rollup-linux-s390x-gnu": "4.56.0", - "@rollup/rollup-linux-x64-gnu": "4.56.0", - "@rollup/rollup-linux-x64-musl": "4.56.0", - "@rollup/rollup-openbsd-x64": "4.56.0", - "@rollup/rollup-openharmony-arm64": "4.56.0", - "@rollup/rollup-win32-arm64-msvc": "4.56.0", - "@rollup/rollup-win32-ia32-msvc": "4.56.0", - "@rollup/rollup-win32-x64-gnu": "4.56.0", - "@rollup/rollup-win32-x64-msvc": "4.56.0", + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } }, diff --git a/package.json b/package.json index 3a82436b..a73a8a09 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-multi-auth", - "version": "0.1.3", + "version": "0.1.4", "description": "OpenAI Codex CLI multi-account OAuth manager with resilient routing and quota-aware diagnostics", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -57,8 +57,10 @@ "bench:edit-formats": "node scripts/benchmark-edit-formats.mjs --preset=codex-core", "bench:edit-formats:smoke": "node scripts/benchmark-edit-formats.mjs --smoke --preset=codex-core", "bench:edit-formats:render": "node scripts/benchmark-render-dashboard.mjs", + "bench:runtime-path": "npm run build && node scripts/benchmark-runtime-path.mjs", + "bench:runtime-path:quick": "node scripts/benchmark-runtime-path.mjs", "test:coverage": "vitest run --coverage", - "coverage": "vitest run --coverage", + "coverage": "npm run build && vitest run --coverage", "audit:prod": "npm audit --omit=dev --audit-level=high", "audit:all": "npm audit --audit-level=high", "audit:dev:allowlist": "node scripts/audit-dev-allowlist.js", @@ -116,14 +118,16 @@ "dependencies": { "@openauthjs/openauth": "^0.4.3", "@codex-ai/plugin": "file:vendor/codex-ai-plugin", - "hono": "^4.12.0", + "hono": "4.12.3", "zod": "^4.3.6" }, "overrides": { - "hono": "^4.12.0", + "hono": "4.12.3", + "minimatch": "10.2.4", + "rollup": "4.59.0", "vite": "^7.3.1", "@typescript-eslint/typescript-estree": { - "minimatch": "^9.0.5" + "minimatch": "9.0.9" } } } diff --git a/scripts/audit-dev-allowlist.js b/scripts/audit-dev-allowlist.js index cf9730c7..8cba7b51 100644 --- a/scripts/audit-dev-allowlist.js +++ b/scripts/audit-dev-allowlist.js @@ -1,20 +1,12 @@ -#!/usr/bin/env node - import { spawnSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; -const ALLOWED_HIGH_OR_CRITICAL_PACKAGES = new Set([ - "eslint", - "ajv", - "@eslint-community/eslint-utils", - "@typescript-eslint/eslint-plugin", - "@typescript-eslint/parser", - "@typescript-eslint/type-utils", - "@typescript-eslint/typescript-estree", - "@typescript-eslint/utils", - "minimatch", +const ALLOWED_HIGH_OR_CRITICAL_ADVISORIES = new Map([ + // Example: + // ["1113465", { package: "minimatch", expiresOn: "2026-06-30" }], ]); -function summarizeVia(via) { +export function summarizeVia(via) { if (!Array.isArray(via)) return []; return via .map((item) => { @@ -27,102 +19,202 @@ function summarizeVia(via) { .slice(0, 5); } -const isWindows = process.platform === "win32"; -const command = isWindows ? process.env.ComSpec || "cmd.exe" : "npm"; -const commandArgs = isWindows - ? ["/d", "/s", "/c", "npm audit --json"] - : ["audit", "--json"]; -const audit = spawnSync(command, commandArgs, { - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - env: { - ...process.env, - // npm run -s can suppress child npm JSON output; force a readable level. - npm_config_loglevel: - process.env.npm_config_loglevel === "silent" - ? "notice" - : process.env.npm_config_loglevel || "notice", - }, -}); - -const stdout = (audit.stdout ?? "").trim(); -const stderr = (audit.stderr ?? "").trim(); -const combined = [stdout, stderr].filter(Boolean).join("\n"); - -if (!combined) { - if ((audit.status ?? 1) === 0) { - console.log("No vulnerabilities found in npm audit output."); - process.exit(0); +export function extractAdvisoryIds(via) { + if (!Array.isArray(via)) return []; + const advisoryIds = []; + for (const item of via) { + if (!item || typeof item !== "object") { + continue; + } + const source = "source" in item ? item.source : undefined; + if (typeof source === "number" || typeof source === "string") { + advisoryIds.push(String(source)); + } + } + return advisoryIds; +} + +export function isAdvisoryAllowed(packageName, advisoryId, now = Date.now()) { + const rule = ALLOWED_HIGH_OR_CRITICAL_ADVISORIES.get(advisoryId); + if (!rule || typeof rule !== "object") { + return false; + } + if (typeof rule.package === "string" && rule.package !== packageName) { + return false; + } + if (typeof rule.expiresOn === "string") { + const expiresAt = Date.parse(rule.expiresOn); + if (!Number.isFinite(expiresAt) || now > expiresAt) { + return false; + } + } + return true; +} + +export function getAuditCommand(platform = process.platform, env = process.env) { + const isWindows = platform === "win32"; + return { + command: isWindows ? env.ComSpec || "cmd.exe" : "npm", + commandArgs: isWindows + ? ["/d", "/s", "/c", "npm audit --json"] + : ["audit", "--json"], + }; +} + +function resolveVulnerabilities(auditJson) { + if ( + auditJson && + typeof auditJson === "object" && + auditJson.vulnerabilities && + typeof auditJson.vulnerabilities === "object" + ) { + return auditJson.vulnerabilities; } - console.error("Failed to read npm audit output."); - process.exit(1); + return {}; } -// npm can emit human-readable success text (no JSON) on some versions/configs. -if (!combined.includes("{") && /found 0 vulnerabilities/i.test(combined)) { - console.log("No vulnerabilities found in npm audit output."); - process.exit(0); +export function partitionHighCriticalVulnerabilities( + vulnerabilities, + advisoryAllowed = isAdvisoryAllowed, +) { + const unexpected = []; + const allowlisted = []; + for (const [name, details] of Object.entries(vulnerabilities)) { + if (!details || typeof details !== "object") continue; + const severity = typeof details.severity === "string" ? details.severity : "unknown"; + if (severity !== "high" && severity !== "critical") continue; + const entry = { + name, + severity, + via: summarizeVia(details.via), + advisoryIds: extractAdvisoryIds(details.via), + fixAvailable: details.fixAvailable ?? false, + }; + const hasAdvisories = entry.advisoryIds.length > 0; + const allAdvisoriesAllowlisted = + hasAdvisories && + entry.advisoryIds.every((advisoryId) => advisoryAllowed(name, advisoryId)); + if (allAdvisoriesAllowlisted) { + allowlisted.push(entry); + continue; + } + unexpected.push(entry); + } + return { unexpected, allowlisted }; } -let auditJson; -try { - const jsonCandidate = - (stdout.startsWith("{") ? stdout : "") || - (stderr.startsWith("{") ? stderr : "") || - combined.slice(combined.indexOf("{")); - auditJson = JSON.parse(jsonCandidate.replace(/^\uFEFF/, "")); -} catch (error) { - console.error("Failed to parse npm audit JSON output."); - if (stderr) { - console.error(stderr); +function parseAuditOutput(audit) { + const stdout = (audit.stdout ?? "").trim(); + const stderr = (audit.stderr ?? "").trim(); + const combined = [stdout, stderr].filter(Boolean).join("\n"); + if (!combined) { + return { + type: "empty", + ok: (audit.status ?? 1) === 0, + stderr, + }; + } + if (!combined.includes("{") && /found 0 vulnerabilities/i.test(combined)) { + return { type: "none" }; + } + try { + const jsonCandidate = + (stdout.startsWith("{") ? stdout : "") || + (stderr.startsWith("{") ? stderr : "") || + combined.slice(combined.indexOf("{")); + return { + type: "json", + json: JSON.parse(jsonCandidate.replace(/^\uFEFF/, "")), + }; + } catch (error) { + return { + type: "parse_error", + stderr, + message: error instanceof Error ? error.message : String(error), + }; } - console.error(error instanceof Error ? error.message : String(error)); - process.exit(1); } -const vulnerabilities = - auditJson && typeof auditJson === "object" && auditJson.vulnerabilities && typeof auditJson.vulnerabilities === "object" - ? auditJson.vulnerabilities - : {}; +export function runAuditDevAllowlist(options = {}) { + const { + platform = process.platform, + env = process.env, + spawn = spawnSync, + log = console.log, + warn = console.warn, + error = console.error, + now = Date.now, + } = options; -const unexpected = []; -const allowlisted = []; + const { command, commandArgs } = getAuditCommand(platform, env); + const audit = spawn(command, commandArgs, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + env: { + ...env, + // npm run -s can suppress child npm JSON output; force a readable level. + npm_config_loglevel: + env.npm_config_loglevel === "silent" + ? "notice" + : env.npm_config_loglevel || "notice", + }, + }); -for (const [name, details] of Object.entries(vulnerabilities)) { - if (!details || typeof details !== "object") continue; - const severity = typeof details.severity === "string" ? details.severity : "unknown"; - if (severity !== "high" && severity !== "critical") continue; + const parsed = parseAuditOutput(audit); + if (parsed.type === "empty") { + if (parsed.ok) { + log("No vulnerabilities found in npm audit output."); + return 0; + } + error("Failed to read npm audit output."); + return 1; + } + if (parsed.type === "none") { + log("No vulnerabilities found in npm audit output."); + return 0; + } + if (parsed.type === "parse_error") { + error("Failed to parse npm audit JSON output."); + if (parsed.stderr) { + error(parsed.stderr); + } + error(parsed.message); + return 1; + } - const entry = { - name, - severity, - via: summarizeVia(details.via), - fixAvailable: details.fixAvailable ?? false, - }; + const vulnerabilities = resolveVulnerabilities(parsed.json); + const { unexpected, allowlisted } = partitionHighCriticalVulnerabilities( + vulnerabilities, + (packageName, advisoryId) => isAdvisoryAllowed(packageName, advisoryId, now()), + ); - if (ALLOWED_HIGH_OR_CRITICAL_PACKAGES.has(name)) { - allowlisted.push(entry); - continue; + if (unexpected.length > 0) { + error("Unexpected high/critical vulnerabilities detected in dev dependency audit:"); + for (const entry of unexpected) { + error( + `- ${entry.name} (${entry.severity}) advisories=${entry.advisoryIds.join(", ") || "none"} via ${entry.via.join(", ") || "unknown"} fixAvailable=${String(entry.fixAvailable)}`, + ); + } + return 1; } - unexpected.push(entry); -} -if (unexpected.length > 0) { - console.error("Unexpected high/critical vulnerabilities detected in dev dependency audit:"); - for (const entry of unexpected) { - console.error( - `- ${entry.name} (${entry.severity}) via ${entry.via.join(", ") || "unknown"} fixAvailable=${String(entry.fixAvailable)}`, - ); + if (allowlisted.length > 0) { + warn("Allowlisted high/critical dev vulnerabilities detected:"); + for (const entry of allowlisted) { + warn( + `- ${entry.name} (${entry.severity}) advisories=${entry.advisoryIds.join(", ") || "none"} via ${entry.via.join(", ") || "unknown"} fixAvailable=${String(entry.fixAvailable)}`, + ); + } + warn("No unexpected high/critical vulnerabilities found."); } - process.exit(1); + + return 0; } -if (allowlisted.length > 0) { - console.warn("Allowlisted high/critical dev vulnerabilities detected:"); - for (const entry of allowlisted) { - console.warn( - `- ${entry.name} (${entry.severity}) via ${entry.via.join(", ") || "unknown"} fixAvailable=${String(entry.fixAvailable)}`, - ); - } - console.warn("No unexpected high/critical vulnerabilities found."); +const isEntryPoint = + typeof process.argv[1] === "string" && + import.meta.url === pathToFileURL(process.argv[1]).href; + +if (isEntryPoint) { + process.exit(runAuditDevAllowlist()); } diff --git a/scripts/benchmark-runtime-path.mjs b/scripts/benchmark-runtime-path.mjs new file mode 100644 index 00000000..2fc857ec --- /dev/null +++ b/scripts/benchmark-runtime-path.mjs @@ -0,0 +1,166 @@ +#!/usr/bin/env node + +import { mkdir, writeFile } from "node:fs/promises"; +import { performance } from "node:perf_hooks"; +import process from "node:process"; +import { dirname, resolve } from "node:path"; +import { filterInput } from "../dist/lib/request/request-transformer.js"; +import { cleanupToolDefinitions } from "../dist/lib/request/helpers/tool-utils.js"; +import { AccountManager } from "../dist/lib/accounts.js"; + +function argValue(args, name) { + const prefix = `${name}=`; + const match = args.find((arg) => arg.startsWith(prefix)); + return match ? match.slice(prefix.length) : undefined; +} + +function parsePositiveInt(value, fallback) { + if (!value) return fallback; + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) return fallback; + return parsed; +} + +function benchmarkCase(name, iterations, fn) { + for (let i = 0; i < 5; i += 1) { + fn(); + } + const start = performance.now(); + for (let i = 0; i < iterations; i += 1) { + fn(); + } + const end = performance.now(); + return { + name, + iterations, + avgMs: Number(((end - start) / iterations).toFixed(6)), + }; +} + +function buildInputItems(size) { + const items = []; + for (let i = 0; i < size; i += 1) { + items.push({ + type: "message", + role: i % 2 === 0 ? "user" : "assistant", + id: `msg_${i}`, + content: [{ type: "input_text", text: `payload-${i}` }], + }); + if (i % 40 === 0) { + items.push({ type: "item_reference", id: `ref_${i}` }); + } + } + return items; +} + +function buildTools(toolCount, propertyCount) { + const tools = []; + for (let i = 0; i < toolCount; i += 1) { + const properties = {}; + const required = []; + for (let j = 0; j < propertyCount; j += 1) { + const key = `field_${j}`; + properties[key] = { type: ["string", "null"], description: `property-${j}` }; + required.push(key); + } + required.push("ghost_field"); + tools.push({ + type: "function", + function: { + name: `tool_${i}`, + parameters: { + type: "object", + properties, + required, + additionalProperties: false, + }, + }, + }); + } + return tools; +} + +function buildManager(accountCount) { + const now = Date.now(); + const accounts = []; + for (let i = 0; i < accountCount; i += 1) { + accounts.push({ + refreshToken: `rt_${i}`, + accessToken: `at_${i}`, + expiresAt: now + 3_600_000, + accountId: `acct_${i}`, + email: `user${i}@example.com`, + enabled: true, + addedAt: now, + lastUsed: 0, + rateLimitResetTimes: {}, + }); + } + return new AccountManager(undefined, { + version: 3, + accounts, + activeIndex: 0, + activeIndexByFamily: {}, + }); +} + +function run() { + const args = process.argv.slice(2); + const iterations = parsePositiveInt(argValue(args, "--iterations"), 30); + const outputPath = argValue(args, "--output"); + + const inputSmall = buildInputItems(400); + const inputLarge = buildInputItems(2000); + const toolsMedium = buildTools(40, 12); + const toolsLarge = buildTools(140, 25); + + const results = [ + benchmarkCase("filterInput_small", iterations, () => { + const out = filterInput(inputSmall); + if (!Array.isArray(out)) throw new Error("filterInput_small failed"); + }), + benchmarkCase("filterInput_large", iterations, () => { + const out = filterInput(inputLarge); + if (!Array.isArray(out)) throw new Error("filterInput_large failed"); + }), + benchmarkCase("cleanupToolDefinitions_medium", iterations, () => { + const out = cleanupToolDefinitions(toolsMedium); + if (!Array.isArray(out)) throw new Error("cleanupToolDefinitions_medium failed"); + }), + benchmarkCase("cleanupToolDefinitions_large", iterations, () => { + const out = cleanupToolDefinitions(toolsLarge); + if (!Array.isArray(out)) throw new Error("cleanupToolDefinitions_large failed"); + }), + benchmarkCase("accountHybridSelection_200", iterations, () => { + const manager = buildManager(200); + for (let i = 0; i < 200; i += 1) { + manager.getCurrentOrNextForFamilyHybrid("codex", "gpt-5-codex", { pidOffsetEnabled: false }); + } + }), + ]; + + const payload = { + generatedAt: new Date().toISOString(), + node: process.version, + iterations, + results, + }; + + if (outputPath) { + const resolved = resolve(outputPath); + return mkdir(dirname(resolved), { recursive: true }).then(() => + writeFile(resolved, `${JSON.stringify(payload, null, 2)}\n`, "utf8"), + ).then(() => { + console.log(`Runtime benchmark written: ${resolved}`); + console.log(JSON.stringify(payload, null, 2)); + }); + } + + console.log(JSON.stringify(payload, null, 2)); + return Promise.resolve(); +} + +run().catch((error) => { + console.error(`Runtime benchmark failed: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +}); diff --git a/scripts/codex.js b/scripts/codex.js index ce83d403..af2592dd 100644 --- a/scripts/codex.js +++ b/scripts/codex.js @@ -32,6 +32,25 @@ async function loadRunCodexMultiAuthCli() { } } +async function autoSyncManagerActiveSelectionIfEnabled() { + const enabled = (process.env.CODEX_MULTI_AUTH_AUTO_SYNC_ON_STARTUP ?? "1").trim() !== "0"; + if (!enabled) return; + + try { + const mod = await import("../dist/lib/codex-manager.js"); + if (typeof mod.autoSyncActiveAccountToCodex !== "function") { + return; + } + await mod.autoSyncActiveAccountToCodex(); + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ERR_MODULE_NOT_FOUND") { + // Non-auth command path should keep forwarding even if dist is missing. + return; + } + // Best effort only: never block official Codex startup on sync failure. + } +} + function resolveRealCodexBin() { const override = (process.env.CODEX_MULTI_AUTH_REAL_CODEX_BIN ?? "").trim(); if (override.length > 0) { @@ -113,6 +132,41 @@ function forwardToRealCodex(codexBin, args) { }); } +function hasCliAuthCredentialsStoreOverride(args) { + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (arg === "-c" || arg === "--config") { + const next = args[i + 1]; + if (!next || !next.includes("=")) continue; + const [key] = next.split("=", 1); + if ((key ?? "").trim() === "cli_auth_credentials_store") { + return true; + } + continue; + } + if (typeof arg === "string" && arg.startsWith("--config=")) { + const assignment = arg.slice("--config=".length); + const [key] = assignment.split("=", 1); + if ((key ?? "").trim() === "cli_auth_credentials_store") { + return true; + } + } + } + return false; +} + +function buildForwardArgs(rawArgs) { + const forceFileAuthStore = (process.env.CODEX_MULTI_AUTH_FORCE_FILE_AUTH_STORE ?? "1").trim() !== "0"; + if (!forceFileAuthStore) return [...rawArgs]; + if (hasCliAuthCredentialsStoreOverride(rawArgs)) return [...rawArgs]; + + return [ + ...rawArgs, + "-c", + 'cli_auth_credentials_store="file"', + ]; +} + function normalizeExitCode(value) { if (typeof value === "number" && Number.isInteger(value)) { return value; @@ -156,5 +210,7 @@ if (!realCodexBin) { process.exit(1); } -const forwardExitCode = await forwardToRealCodex(realCodexBin, rawArgs); +await autoSyncManagerActiveSelectionIfEnabled(); +const forwardArgs = buildForwardArgs(rawArgs); +const forwardExitCode = await forwardToRealCodex(realCodexBin, forwardArgs); process.exit(forwardExitCode); diff --git a/scripts/copy-oauth-success.js b/scripts/copy-oauth-success.js index d37f2e0b..22f63983 100644 --- a/scripts/copy-oauth-success.js +++ b/scripts/copy-oauth-success.js @@ -1,4 +1,4 @@ -import { promises as fs } from "node:fs"; +import * as fs from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -16,20 +16,59 @@ function getDefaultPaths() { return { src, dest }; } +/** + * Copy a file and retry automatically while the destination is temporarily locked. + * @param {string} src absolute path to the source HTML file + * @param {string} dest absolute path to the destination HTML file + * @param {{ maxAttempts?: number, backoffMs?: number }} options retry configuration + */ +async function copyWithRetry( + src, + dest, + { maxAttempts = 3, backoffMs = 50 } = {}, +) { + const retryableCodes = new Set(["EBUSY", "EPERM", "EACCES"]); + let attempt = 0; + for (;;) { + try { + await fs.copyFile(src, dest); + return; + } catch (err) { + const code = err && typeof err === "object" && "code" in err ? err.code : undefined; + const isRetryable = typeof code === "string" && retryableCodes.has(code); + if (!isRetryable || attempt >= maxAttempts - 1) { + throw err; + } + attempt += 1; + const delayMs = backoffMs * (2 ** (attempt - 1)); + await new Promise((resolve) => { + setTimeout(resolve, delayMs); + }); + } + } +} + +/** + * Copy the OAuth success HTML into dist/, ensuring safe directory creation and retries. + * @param {{ src?: string, dest?: string }} options optional override paths for testing + */ export async function copyOAuthSuccessHtml(options = {}) { const defaults = getDefaultPaths(); const src = options.src ?? defaults.src; const dest = options.dest ?? defaults.dest; await fs.mkdir(dirname(dest), { recursive: true }); - await fs.copyFile(src, dest); + await copyWithRetry(src, dest); return { src, dest }; } const isDirectRun = (() => { if (!process.argv[1]) return false; - return normalizePathForCompare(process.argv[1]) === normalizePathForCompare(__filename); + return ( + normalizePathForCompare(process.argv[1]) === + normalizePathForCompare(__filename) + ); })(); if (isDirectRun) { diff --git a/scripts/install-codex-auth-utils.js b/scripts/install-codex-auth-utils.js index af36aa2e..27e90cb2 100644 --- a/scripts/install-codex-auth-utils.js +++ b/scripts/install-codex-auth-utils.js @@ -1,7 +1,12 @@ import { join } from "node:path"; import { homedir } from "node:os"; +import { rename as fsRename } from "node:fs/promises"; const PLUGIN_NAME = "codex-multi-auth"; +export const FILE_RETRY_CODES = new Set(["EBUSY", "EPERM", "EAGAIN", "ENOTEMPTY", "EACCES"]); +export const FILE_RETRY_MAX_ATTEMPTS = 6; +export const FILE_RETRY_BASE_DELAY_MS = 25; +export const FILE_RETRY_JITTER_MS = 20; export function resolveInstallPaths( platform = process.platform, @@ -47,3 +52,67 @@ export function normalizePluginList(list) { return [...deduped, PLUGIN_NAME]; } +function sleep(ms) { + // Keep this helper local so installer scripts remain standalone and do not depend on lib/. + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +function shouldRetryFileOperation(error) { + return error instanceof Error && + typeof error.code === "string" && + FILE_RETRY_CODES.has(error.code); +} + +export async function withFileOperationRetry(operation) { + for (let attempt = 1; ; attempt += 1) { + try { + return await operation(); + } catch (error) { + if (!shouldRetryFileOperation(error) || attempt >= FILE_RETRY_MAX_ATTEMPTS) { + throw error; + } + + const jitter = Math.floor(Math.random() * FILE_RETRY_JITTER_MS); + const delayMs = (FILE_RETRY_BASE_DELAY_MS * (2 ** (attempt - 1))) + jitter; + await sleep(delayMs); + } + } +} + +export async function renameWithRetry(sourcePath, targetPath, options = {}) { + const { + rename = fsRename, + log = () => {}, + maxRetries = FILE_RETRY_MAX_ATTEMPTS, + baseDelayMs = FILE_RETRY_BASE_DELAY_MS, + jitterMs = FILE_RETRY_JITTER_MS, + random = Math.random, + sleep: sleepImpl = sleep, + } = options; + + if (!Number.isInteger(maxRetries) || maxRetries < 1) { + throw new RangeError("maxRetries must be an integer >= 1"); + } + + for (let attempt = 0; attempt < maxRetries; attempt += 1) { + try { + await rename(sourcePath, targetPath); + return; + } catch (error) { + const code = error && typeof error === "object" && "code" in error + ? error.code + : undefined; + const isRetryable = typeof code === "string" && FILE_RETRY_CODES.has(code); + if (!isRetryable || attempt === maxRetries - 1) { + throw error; + } + const delayMs = baseDelayMs * 2 ** attempt + Math.floor(random() * jitterMs); + log( + `Retrying atomic rename (${attempt + 1}/${maxRetries}) code=${code ?? "unknown"} source=${sourcePath} target=${targetPath} delayMs=${delayMs}`, + ); + await sleepImpl(delayMs); + } + } +} diff --git a/scripts/install-codex-auth.js b/scripts/install-codex-auth.js index d1ef21c7..5807c38c 100644 --- a/scripts/install-codex-auth.js +++ b/scripts/install-codex-auth.js @@ -1,10 +1,15 @@ #!/usr/bin/env node import { existsSync } from "node:fs"; -import { readFile, writeFile, mkdir, copyFile, rm, rename } from "node:fs/promises"; +import { readFile, writeFile, mkdir, copyFile, rm } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { dirname, join, resolve } from "node:path"; -import { normalizePluginList, resolveInstallPaths } from "./install-codex-auth-utils.js"; +import { + normalizePluginList, + renameWithRetry, + resolveInstallPaths, + withFileOperationRetry, +} from "./install-codex-auth-utils.js"; const PLUGIN_NAME = "codex-multi-auth"; @@ -61,11 +66,15 @@ async function writeJsonAtomic(filePath, value) { .slice(2, 8)}`; const content = formatJson(value); try { - await writeFile(tempPath, content, "utf-8"); - await rename(tempPath, filePath); + await withFileOperationRetry(() => writeFile(tempPath, content, "utf-8")); + await renameWithRetry(tempPath, filePath, { log }); } finally { if (existsSync(tempPath)) { - await rm(tempPath, { force: true }); + try { + await withFileOperationRetry(() => rm(tempPath, { force: true })); + } catch (error) { + log(`Warning: Could not remove temporary file ${tempPath} (${error}).`); + } } } } @@ -136,8 +145,8 @@ async function clearCache() { log(`[dry-run] Would remove ${cacheBunLock}`); } else { try { - await rm(cacheNodeModules, { recursive: true, force: true }); - await rm(cacheBunLock, { force: true }); + await withFileOperationRetry(() => rm(cacheNodeModules, { recursive: true, force: true })); + await withFileOperationRetry(() => rm(cacheBunLock, { force: true })); } catch (error) { log( `Warning: Could not fully clear cache (${error instanceof Error ? error.message : String(error)}). You may need to restart Codex.`, diff --git a/test/__snapshots__/copy-oauth-success.test.ts.snap b/test/__snapshots__/copy-oauth-success.test.ts.snap new file mode 100644 index 00000000..cff6c0a9 --- /dev/null +++ b/test/__snapshots__/copy-oauth-success.test.ts.snap @@ -0,0 +1,275 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`copy-oauth-success script > copies oauth-success.html to the requested destination and matches snapshot 1`] = ` +" + + + + + OpenAI Codex Callback Received + + + +
+

OpenAI Codex

+ +

Callback received

+

Return to your terminal to complete sign-in.

+
+ Status + Processing +
+
    +
  • + Authorization code + received +
  • +
  • + Token exchange + running in terminal +
  • +
  • + Credentials + persisting after terminal completes +
  • +
  • + Session + activating in terminal +
  • +
+
+
+ Callback + + localhost:1455 +
+
+ Tip + Close this tab when you are done. +
+
+

Next: return to the terminal window to continue.

+
Back to terminal to continue.
+
+ + +" +`; diff --git a/test/accounts-edge.test.ts b/test/accounts-edge.test.ts new file mode 100644 index 00000000..31c34b07 --- /dev/null +++ b/test/accounts-edge.test.ts @@ -0,0 +1,418 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OAuthAuthDetails } from "../lib/types.js"; + +const mockLoadAccounts = vi.fn(); +const mockSaveAccounts = vi.fn(); +const mockLoadCodexCliState = vi.fn(); +const mockSyncAccountStorageFromCodexCli = vi.fn(); +const mockSetCodexCliActiveSelection = vi.fn(); +const mockSelectHybridAccount = vi.fn(); + +vi.mock("../lib/storage.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadAccounts: mockLoadAccounts, + saveAccounts: mockSaveAccounts, + }; +}); + +vi.mock("../lib/codex-cli/state.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + loadCodexCliState: mockLoadCodexCliState, + }; +}); + +vi.mock("../lib/codex-cli/sync.js", () => ({ + syncAccountStorageFromCodexCli: mockSyncAccountStorageFromCodexCli, +})); + +vi.mock("../lib/codex-cli/writer.js", () => ({ + setCodexCliActiveSelection: mockSetCodexCliActiveSelection, +})); + +vi.mock("../lib/rotation.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + selectHybridAccount: mockSelectHybridAccount, + }; +}); + +function buildStoredAccount( + overrides: Record = {}, +): Record { + return { + refreshToken: "stored-refresh", + addedAt: Date.now() - 10_000, + lastUsed: Date.now() - 5_000, + ...overrides, + }; +} + +function buildStored( + accounts: Record[], +): Record { + return { + version: 3, + activeIndex: 0, + accounts, + }; +} + +function setPrivate(target: object, key: string, value: unknown): void { + Reflect.set(target, key, value); +} + +function getPrivate(target: object, key: string): T { + return Reflect.get(target, key) as T; +} + +async function importAccountsModule() { + vi.resetModules(); + return import("../lib/accounts.js"); +} + +describe("accounts edge branches", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockLoadAccounts.mockResolvedValue(null); + mockSaveAccounts.mockResolvedValue(undefined); + mockLoadCodexCliState.mockResolvedValue(null); + mockSyncAccountStorageFromCodexCli.mockImplementation(async (storage) => ({ + storage, + changed: false, + })); + mockSetCodexCliActiveSelection.mockResolvedValue(undefined); + mockSelectHybridAccount.mockImplementation( + (accounts: { index: number; isAvailable: boolean }[]) => { + const available = accounts.find((candidate) => candidate.isAvailable); + return available ? { index: available.index } : null; + }, + ); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("loadFromDisk tolerates sync persistence failures", async () => { + const stored = buildStored([ + buildStoredAccount({ refreshToken: "stored-1" }), + ]); + mockLoadAccounts.mockResolvedValue(stored); + mockSyncAccountStorageFromCodexCli.mockResolvedValue({ + storage: stored, + changed: true, + }); + mockSaveAccounts.mockRejectedValueOnce(new Error("persist failed")); + mockLoadCodexCliState.mockResolvedValue({ accounts: [] }); + + const { AccountManager } = await importAccountsModule(); + const manager = await AccountManager.loadFromDisk(); + + expect(manager.getAccountCount()).toBe(1); + expect(mockSaveAccounts).toHaveBeenCalledTimes(1); + }); + + it("hydrates from Codex CLI cache and catches save failures", async () => { + const now = Date.now(); + const stored = buildStored([ + buildStoredAccount({ + refreshToken: "refresh-1", + email: "match@example.com", + accessToken: "", + expiresAt: now - 5_000, + }), + buildStoredAccount({ + refreshToken: "refresh-2", + email: "expired@example.com", + accessToken: "existing-access", + expiresAt: now + 120_000, + }), + buildStoredAccount({ + refreshToken: "refresh-3", + }), + buildStoredAccount({ + refreshToken: "refresh-4", + email: "missing@example.com", + accessToken: "existing-access", + expiresAt: now + 120_000, + }), + ]); + + const { AccountManager } = await importAccountsModule(); + const manager = new AccountManager(undefined, stored as never); + + mockLoadCodexCliState.mockResolvedValue({ + accounts: [ + { email: "", accessToken: "invalid" }, + { + email: "match@example.com", + accessToken: "refreshed-access", + expiresAt: now + 300_000, + accountId: "account-from-cache", + }, + { + email: "expired@example.com", + accessToken: "expired-access", + expiresAt: now - 1, + accountId: "expired-id", + }, + { + email: "no-token@example.com", + accessToken: "", + }, + ], + }); + + mockSaveAccounts.mockRejectedValueOnce(new Error("save failed")); + + const hydrate = getPrivate<() => Promise>( + manager as object, + "hydrateFromCodexCli", + ); + await hydrate.call(manager); + + const snapshot = manager.getAccountsSnapshot(); + const updated = snapshot[0]; + expect(updated?.access).toBe("refreshed-access"); + expect(updated?.accountId).toBe("account-from-cache"); + expect(updated?.accountIdSource).toBe("token"); + + const expired = snapshot[1]; + expect(expired?.access).toBe("existing-access"); + expect(expired?.accountId).toBeUndefined(); + }); + + it("returns early when Codex CLI state has no usable cache entries", async () => { + const stored = buildStored([ + buildStoredAccount({ + refreshToken: "refresh-1", + email: "user@example.com", + }), + ]); + + const { AccountManager } = await importAccountsModule(); + const manager = new AccountManager(undefined, stored as never); + + mockLoadCodexCliState.mockResolvedValue({ + accounts: [ + { email: "", accessToken: "x" }, + { email: "missing-token@example.com", accessToken: "" }, + ], + }); + + const hydrate = getPrivate<() => Promise>( + manager as object, + "hydrateFromCodexCli", + ); + await hydrate.call(manager); + + expect(mockSaveAccounts).not.toHaveBeenCalled(); + }); + + it("handles invalid indices and sparse accounts for active selection sync", async () => { + const stored = buildStored([ + buildStoredAccount({ + refreshToken: "refresh-1", + accessToken: "access-1", + }), + ]); + + const { AccountManager } = await importAccountsModule(); + const manager = new AccountManager(undefined, stored as never); + + await manager.syncCodexCliActiveSelectionForIndex(Number.NaN); + await manager.syncCodexCliActiveSelectionForIndex(-1); + await manager.syncCodexCliActiveSelectionForIndex(99); + expect(mockSetCodexCliActiveSelection).not.toHaveBeenCalled(); + + setPrivate(manager as object, "accounts", new Array(1)); + await manager.syncCodexCliActiveSelectionForIndex(0); + expect(mockSetCodexCliActiveSelection).not.toHaveBeenCalled(); + + setPrivate(manager as object, "accounts", [ + { + index: 0, + refreshToken: "refresh-1", + access: "access-1", + expires: Date.now() + 60_000, + addedAt: Date.now() - 10_000, + lastUsed: Date.now() - 5_000, + rateLimitResetTimes: {}, + enabled: true, + }, + ]); + + await manager.syncCodexCliActiveSelectionForIndex(0); + expect(mockSetCodexCliActiveSelection).toHaveBeenCalledTimes(1); + }); + + it("covers sparse and disabled account branches in family selectors", async () => { + const stored = buildStored([ + buildStoredAccount({ refreshToken: "refresh-1" }), + buildStoredAccount({ refreshToken: "refresh-2" }), + ]); + + const { AccountManager } = await importAccountsModule(); + const manager = new AccountManager(undefined, stored as never); + + const sparseAccounts = [ + undefined, + { + index: 1, + refreshToken: "refresh-2", + enabled: false, + addedAt: Date.now() - 10_000, + lastUsed: Date.now() - 5_000, + rateLimitResetTimes: {}, + }, + ]; + setPrivate(manager as object, "accounts", sparseAccounts); + + const currentByFamily = getPrivate>( + manager as object, + "currentAccountIndexByFamily", + ); + const cursorByFamily = getPrivate>( + manager as object, + "cursorByFamily", + ); + + currentByFamily.codex = 0; + cursorByFamily.codex = 0; + + expect(manager.getCurrentAccountForFamily("codex")).toBeNull(); + expect(manager.getCurrentOrNextForFamily("codex")).toBeNull(); + expect(manager.getNextForFamily("codex")).toBeNull(); + + currentByFamily.codex = 1; + mockSelectHybridAccount.mockReturnValueOnce(null); + expect(manager.getCurrentOrNextForFamilyHybrid("codex")).toBeNull(); + + currentByFamily.codex = 0; + mockSelectHybridAccount.mockReturnValueOnce({ index: 999 }); + expect(manager.getCurrentOrNextForFamilyHybrid("codex")).toBeNull(); + }); + + it("covers remove/set-by-index guard branches including sparse slots", async () => { + const stored = buildStored([ + buildStoredAccount({ refreshToken: "refresh-1" }), + ]); + + const { AccountManager } = await importAccountsModule(); + const manager = new AccountManager(undefined, stored as never); + + expect(manager.removeAccountByIndex(Number.NaN)).toBe(false); + expect(manager.removeAccountByIndex(-1)).toBe(false); + expect(manager.removeAccountByIndex(99)).toBe(false); + + expect(manager.setAccountEnabled(Number.NaN, true)).toBeNull(); + expect(manager.setAccountEnabled(-1, true)).toBeNull(); + expect(manager.setAccountEnabled(99, true)).toBeNull(); + + setPrivate(manager as object, "accounts", new Array(1)); + + expect(manager.removeAccountByIndex(0)).toBe(false); + expect(manager.setAccountEnabled(0, true)).toBeNull(); + }); + + it("saves disabled accounts and flushes an in-flight pending save", async () => { + const stored = buildStored([ + buildStoredAccount({ + refreshToken: "refresh-1", + enabled: false, + accessToken: "", + }), + ]); + + const { AccountManager } = await importAccountsModule(); + const manager = new AccountManager(undefined, stored as never); + + await manager.saveToDisk(); + const payload = mockSaveAccounts.mock.calls[0]?.[0] as { + accounts: Array<{ enabled?: boolean }>; + }; + expect(payload.accounts[0]?.enabled).toBe(false); + + let resolvePending: (() => void) | null = null; + const pendingSave = new Promise((resolve) => { + resolvePending = resolve; + }); + setPrivate(manager as object, "pendingSave", pendingSave); + + const flushPromise = manager.flushPendingSave(); + resolvePending?.(); + await flushPromise; + }); + + it("waits on pending save inside debounced save and handles non-Error failures", async () => { + vi.useFakeTimers(); + const stored = buildStored([ + buildStoredAccount({ refreshToken: "refresh-1" }), + ]); + + const { AccountManager } = await importAccountsModule(); + const manager = new AccountManager(undefined, stored as never); + + let resolvePending: (() => void) | null = null; + const pendingSave = new Promise((resolve) => { + resolvePending = resolve; + }); + setPrivate(manager as object, "pendingSave", pendingSave); + + mockSaveAccounts.mockRejectedValueOnce("string-save-failure"); + + manager.saveToDiskDebounced(20); + resolvePending?.(); + await vi.advanceTimersByTimeAsync(100); + + expect(mockSaveAccounts).toHaveBeenCalled(); + }); + + it("covers getMinWaitTimeForFamily when all accounts are disabled", async () => { + const stored = buildStored([ + buildStoredAccount({ refreshToken: "refresh-1", enabled: false }), + buildStoredAccount({ refreshToken: "refresh-2", enabled: false }), + ]); + + const { AccountManager } = await importAccountsModule(); + const manager = new AccountManager(undefined, stored as never); + + expect(manager.getMinWaitTimeForFamily("codex")).toBe(0); + }); + + it("matches fallback auth by refresh token and preserves existing account id when token lacks one", async () => { + const now = Date.now(); + const stored = buildStored([ + buildStoredAccount({ + refreshToken: "refresh-token", + accountId: "existing-account-id", + accountIdSource: "manual", + }), + ]); + + const emailPayload = Buffer.from( + JSON.stringify({ email: "edge@example.com" }), + ).toString("base64"); + const auth: OAuthAuthDetails = { + type: "oauth", + access: `header.${emailPayload}.signature`, + refresh: "refresh-token", + expires: now + 60_000, + }; + + const { AccountManager } = await importAccountsModule(); + const manager = new AccountManager(auth, stored as never); + + const account = manager.getCurrentAccount(); + expect(account?.refreshToken).toBe("refresh-token"); + expect(account?.accountId).toBe("existing-account-id"); + expect(account?.accountIdSource).toBe("manual"); + expect(account?.email).toBe("edge@example.com"); + }); +}); diff --git a/test/accounts-load-from-disk.test.ts b/test/accounts-load-from-disk.test.ts new file mode 100644 index 00000000..61c2b8b0 --- /dev/null +++ b/test/accounts-load-from-disk.test.ts @@ -0,0 +1,256 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AccountManager } from "../lib/accounts.js"; + +vi.mock("../lib/storage.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadAccounts: vi.fn(), + saveAccounts: vi.fn().mockResolvedValue(undefined), + }; +}); + +vi.mock("../lib/codex-cli/sync.js", () => ({ + syncAccountStorageFromCodexCli: vi.fn(), +})); + +vi.mock("../lib/codex-cli/state.js", () => ({ + loadCodexCliState: vi.fn(), +})); + +vi.mock("../lib/codex-cli/writer.js", () => ({ + setCodexCliActiveSelection: vi.fn().mockResolvedValue(undefined), +})); + +import { loadAccounts, saveAccounts } from "../lib/storage.js"; +import { syncAccountStorageFromCodexCli } from "../lib/codex-cli/sync.js"; +import { loadCodexCliState } from "../lib/codex-cli/state.js"; +import { setCodexCliActiveSelection } from "../lib/codex-cli/writer.js"; + +describe("AccountManager loadFromDisk", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(loadAccounts).mockResolvedValue(null); + vi.mocked(saveAccounts).mockResolvedValue(undefined); + vi.mocked(syncAccountStorageFromCodexCli).mockResolvedValue({ + changed: false, + storage: null, + }); + vi.mocked(loadCodexCliState).mockResolvedValue(null); + vi.mocked(setCodexCliActiveSelection).mockResolvedValue(undefined); + }); + + it("persists Codex CLI source-of-truth storage when sync reports change", async () => { + const now = Date.now(); + const stored = { + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "stored-refresh", addedAt: now, lastUsed: now }], + }; + const synced = { + version: 3 as const, + activeIndex: 0, + accounts: [ + { refreshToken: "stored-refresh", addedAt: now, lastUsed: now }, + { refreshToken: "synced-refresh", addedAt: now + 1, lastUsed: now + 1 }, + ], + }; + + vi.mocked(loadAccounts).mockResolvedValue(stored); + vi.mocked(syncAccountStorageFromCodexCli).mockResolvedValue({ + changed: true, + storage: synced, + }); + + const manager = await AccountManager.loadFromDisk(); + + expect(saveAccounts).toHaveBeenCalledWith(synced); + expect(manager.getAccountCount()).toBe(2); + expect(manager.getCurrentAccount()?.refreshToken).toBe("stored-refresh"); + }); + + it("swallows source-of-truth persist failures and still returns a manager", async () => { + const now = Date.now(); + const synced = { + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "synced-refresh", addedAt: now, lastUsed: now }], + }; + + vi.mocked(syncAccountStorageFromCodexCli).mockResolvedValue({ + changed: true, + storage: synced, + }); + vi.mocked(saveAccounts).mockRejectedValueOnce(new Error("forced persist failure")); + + const manager = await AccountManager.loadFromDisk(); + + expect(manager.getAccountCount()).toBe(1); + expect(manager.getCurrentAccount()?.refreshToken).toBe("synced-refresh"); + }); + + it("hydrates missing access/accountId fields from Codex CLI token cache", async () => { + const now = Date.now(); + vi.mocked(loadAccounts).mockResolvedValue({ + version: 3 as const, + activeIndex: 0, + accounts: [ + { + refreshToken: "refresh-1", + email: "user@example.com", + addedAt: now, + lastUsed: now, + }, + ], + }); + vi.mocked(loadCodexCliState).mockResolvedValue({ + path: "codex-state.json", + accounts: [ + { + email: "USER@EXAMPLE.COM", + accessToken: "cached-access-token", + expiresAt: now + 120_000, + accountId: "acct-123", + }, + ], + }); + + const manager = await AccountManager.loadFromDisk(); + const account = manager.getCurrentAccount(); + + expect(account?.access).toBe("cached-access-token"); + expect(account?.expires).toBe(now + 120_000); + expect(account?.accountId).toBe("acct-123"); + expect(account?.accountIdSource).toBe("token"); + expect(saveAccounts).toHaveBeenCalledTimes(1); + }); + + it("skips expired Codex CLI cache entries and does not persist", async () => { + const now = Date.now(); + vi.mocked(loadAccounts).mockResolvedValue({ + version: 3 as const, + activeIndex: 0, + accounts: [ + { + refreshToken: "refresh-1", + email: "user@example.com", + addedAt: now, + lastUsed: now, + }, + ], + }); + vi.mocked(loadCodexCliState).mockResolvedValue({ + path: "codex-state.json", + accounts: [ + { + email: "user@example.com", + accessToken: "expired-access-token", + expiresAt: now - 1, + accountId: "acct-expired", + }, + ], + }); + + const manager = await AccountManager.loadFromDisk(); + const account = manager.getCurrentAccount(); + + expect(account?.access).toBeUndefined(); + expect(account?.accountId).toBeUndefined(); + expect(saveAccounts).not.toHaveBeenCalled(); + }); + + it("syncCodexCliActiveSelectionForIndex ignores invalid indices and syncs a valid one", async () => { + const now = Date.now(); + const manager = new AccountManager(undefined, { + version: 3 as const, + activeIndex: 0, + accounts: [ + { + refreshToken: "refresh-1", + accountId: "acct-1", + email: "one@example.com", + accessToken: "access-1", + expiresAt: now + 10_000, + addedAt: now, + lastUsed: now, + } as never, + ], + }); + + await manager.syncCodexCliActiveSelectionForIndex(-1); + await manager.syncCodexCliActiveSelectionForIndex(9); + expect(setCodexCliActiveSelection).not.toHaveBeenCalled(); + + await manager.syncCodexCliActiveSelectionForIndex(0); + expect(setCodexCliActiveSelection).toHaveBeenCalledTimes(1); + expect(setCodexCliActiveSelection).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "acct-1", + email: "one@example.com", + refreshToken: "refresh-1", + }), + ); + }); + + it("getNextForFamily skips disabled/rate-limited/cooldown accounts", () => { + const now = Date.now(); + const manager = new AccountManager(undefined, { + version: 3 as const, + activeIndex: 0, + accounts: [ + { + refreshToken: "disabled", + enabled: false, + addedAt: now, + lastUsed: now, + }, + { + refreshToken: "cooldown", + coolingDownUntil: now + 60_000, + cooldownReason: "auth-failure", + addedAt: now, + lastUsed: now, + }, + { + refreshToken: "rate-limited", + rateLimitResetTimes: { codex: now + 60_000 }, + addedAt: now, + lastUsed: now, + }, + { + refreshToken: "available", + addedAt: now, + lastUsed: now, + }, + ], + } as never); + + const selected = manager.getNextForFamily("codex"); + expect(selected?.refreshToken).toBe("available"); + }); + + it("getNextForFamily returns null when all accounts are unavailable", () => { + const now = Date.now(); + const manager = new AccountManager(undefined, { + version: 3 as const, + activeIndex: 0, + accounts: [ + { + refreshToken: "disabled", + enabled: false, + addedAt: now, + lastUsed: now, + }, + { + refreshToken: "cooldown", + coolingDownUntil: now + 60_000, + cooldownReason: "network-error", + addedAt: now, + lastUsed: now, + }, + ], + } as never); + + expect(manager.getNextForFamily("codex")).toBeNull(); + }); +}); diff --git a/test/audit-dev-allowlist.test.ts b/test/audit-dev-allowlist.test.ts new file mode 100644 index 00000000..c0e5990e --- /dev/null +++ b/test/audit-dev-allowlist.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from "vitest"; +import { + extractAdvisoryIds, + getAuditCommand, + partitionHighCriticalVulnerabilities, + runAuditDevAllowlist, +} from "../scripts/audit-dev-allowlist.js"; + +describe("audit-dev-allowlist helpers", () => { + it("keeps advisories with string/number sources and drops invalid sources", () => { + expect(extractAdvisoryIds([ + { source: 12345 }, + { source: "GHSA-abc" }, + { source: {} }, + { source: true }, + {}, + ])).toEqual(["12345", "GHSA-abc"]); + }); + + it("treats missing/invalid advisory sources as unexpected even when allow predicate returns true", () => { + const vulnerabilities = { + "pkg-missing-source": { + severity: "high", + via: [{ name: "dep-without-source", range: "<1.0.0" }], + fixAvailable: false, + }, + "pkg-invalid-source": { + severity: "critical", + via: [{ name: "dep-invalid-source", source: { id: "not-supported" } }], + fixAvailable: true, + }, + }; + + const result = partitionHighCriticalVulnerabilities( + vulnerabilities, + () => true, + ); + + expect(result.allowlisted).toEqual([]); + expect(result.unexpected).toHaveLength(2); + expect(result.unexpected[0]?.advisoryIds).toEqual([]); + expect(result.unexpected[1]?.advisoryIds).toEqual([]); + }); + + it("uses cmd.exe execution path on Windows", () => { + const command = getAuditCommand("win32", { ComSpec: "C:\\Windows\\System32\\cmd.exe" }); + expect(command).toEqual({ + command: "C:\\Windows\\System32\\cmd.exe", + commandArgs: ["/d", "/s", "/c", "npm audit --json"], + }); + }); + + it("routes runAuditDevAllowlist through cmd.exe on Windows", () => { + const spawn = vi.fn().mockReturnValue({ + status: 0, + stdout: "found 0 vulnerabilities", + stderr: "", + }); + const logs: string[] = []; + const code = runAuditDevAllowlist({ + platform: "win32", + env: { ComSpec: "C:\\Windows\\System32\\cmd.exe" }, + spawn, + log: (message) => logs.push(String(message)), + warn: () => {}, + error: () => {}, + }); + + expect(code).toBe(0); + expect(spawn).toHaveBeenCalledTimes(1); + expect(spawn).toHaveBeenCalledWith( + "C:\\Windows\\System32\\cmd.exe", + ["/d", "/s", "/c", "npm audit --json"], + expect.any(Object), + ); + expect(logs).toContain("No vulnerabilities found in npm audit output."); + }); +}); diff --git a/test/auth-logging.test.ts b/test/auth-logging.test.ts new file mode 100644 index 00000000..e34e5dc3 --- /dev/null +++ b/test/auth-logging.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../lib/logger.js', () => ({ + logError: vi.fn(), +})); + +import { logError } from '../lib/logger.js'; +import { exchangeAuthorizationCode } from '../lib/auth/auth.js'; + +describe('OAuth auth logging', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + }); + + it('logs safe metadata when token response schema validation fails', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn(async () => + new Response(JSON.stringify({ + access_token: 'secret-access-token', + refresh_token: 'secret-refresh-token', + expires_in: '3600', + }), { status: 200 }), + ) as never; + + try { + const result = await exchangeAuthorizationCode('auth-code', 'verifier-123'); + expect(result.type).toBe('failed'); + + expect(vi.mocked(logError)).toHaveBeenCalledWith( + 'token response validation failed', + { responseType: 'object', keyCount: 3 }, + ); + + const loggedData = vi.mocked(logError).mock.calls[0]?.[1] as Record | undefined; + expect(loggedData).toEqual({ responseType: 'object', keyCount: 3 }); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('logs safe metadata when refresh token is missing', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn(async () => + new Response(JSON.stringify({ + access_token: 'secret-access-token', + expires_in: 3600, + }), { status: 200 }), + ) as never; + + try { + const result = await exchangeAuthorizationCode('auth-code', 'verifier-123'); + expect(result.type).toBe('failed'); + + expect(vi.mocked(logError)).toHaveBeenCalledWith( + 'token response missing refresh token', + { responseType: 'object', keyCount: 2 }, + ); + + const loggedData = vi.mocked(logError).mock.calls[0]?.[1] as Record | undefined; + expect(loggedData).toEqual({ responseType: 'object', keyCount: 2 }); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); diff --git a/test/auth.test.ts b/test/auth.test.ts index 8b4459c1..fe7affad 100644 --- a/test/auth.test.ts +++ b/test/auth.test.ts @@ -12,6 +12,7 @@ import { REDIRECT_URI, SCOPE, } from '../lib/auth/auth.js'; +import * as loggerModule from '../lib/logger.js'; describe('Auth Module', () => { describe('createState', () => { @@ -280,7 +281,7 @@ describe('Auth Module', () => { } }); - it('returns success with empty refresh token when not provided', async () => { + it('returns failed when refresh token is missing', async () => { vi.spyOn(Date, 'now').mockReturnValue(1_000); const originalFetch = globalThis.fetch; globalThis.fetch = vi.fn(async () => @@ -292,14 +293,35 @@ describe('Auth Module', () => { try { const result = await exchangeAuthorizationCode('auth-code', 'verifier-123'); - expect(result).toEqual({ - type: 'success', - access: 'access-123', - refresh: '', - expires: 3_601_000, - idToken: undefined, - multiAccount: true, - }); + expect(result.type).toBe('failed'); + if (result.type === 'failed') { + expect(result.reason).toBe('invalid_response'); + expect(result.message).toContain('Missing refresh token'); + } + } finally { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + } + }); + + it('returns failed when refresh token is whitespace only', async () => { + vi.spyOn(Date, 'now').mockReturnValue(1_000); + const originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn(async () => + new Response(JSON.stringify({ + access_token: 'access-123', + refresh_token: ' ', + expires_in: 3600, + }), { status: 200 }), + ) as never; + + try { + const result = await exchangeAuthorizationCode('auth-code', 'verifier-123'); + expect(result.type).toBe('failed'); + if (result.type === 'failed') { + expect(result.reason).toBe('invalid_response'); + expect(result.message).toContain('Missing refresh token'); + } } finally { globalThis.fetch = originalFetch; vi.restoreAllMocks(); @@ -463,6 +485,78 @@ describe('Auth Module', () => { } }); + it('returns non-network failure for aborted refresh requests', async () => { + const originalFetch = globalThis.fetch; + const abortError = Object.assign(new Error('Request aborted'), { name: 'AbortError' }); + globalThis.fetch = vi.fn(async () => { + throw abortError; + }) as never; + + try { + const controller = new AbortController(); + controller.abort(abortError); + const result = await refreshAccessToken('some-token', { signal: controller.signal }); + expect(result.type).toBe('failed'); + if (result.type === 'failed') { + expect(result.reason).toBe('unknown'); + expect(result.message).toBe('Request aborted'); + } + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('returns failed when response refresh token is whitespace only', async () => { + const originalFetch = globalThis.fetch; + const logErrorSpy = vi.spyOn(loggerModule, 'logError').mockImplementation(() => {}); + globalThis.fetch = vi.fn(async () => + new Response(JSON.stringify({ + access_token: 'new-access', + refresh_token: ' ', + expires_in: 60, + }), { status: 200 }), + ) as never; + + try { + const result = await refreshAccessToken('existing-refresh'); + expect(result.type).toBe('failed'); + if (result.type === 'failed') { + expect(result.reason).toBe('missing_refresh'); + expect(result.message).toBe('No refresh token in response or input'); + } + expect(logErrorSpy).toHaveBeenCalledWith('Token refresh missing refresh token'); + expect(logErrorSpy.mock.calls[0]).toEqual(['Token refresh missing refresh token']); + } finally { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + } + }); + + it('returns failed when input refresh token is whitespace only and response omits refresh token', async () => { + const originalFetch = globalThis.fetch; + const logErrorSpy = vi.spyOn(loggerModule, 'logError').mockImplementation(() => {}); + globalThis.fetch = vi.fn(async () => + new Response(JSON.stringify({ + access_token: 'new-access', + expires_in: 60, + }), { status: 200 }), + ) as never; + + try { + const result = await refreshAccessToken(' '); + expect(result.type).toBe('failed'); + if (result.type === 'failed') { + expect(result.reason).toBe('missing_refresh'); + expect(result.message).toBe('No refresh token in response or input'); + } + expect(logErrorSpy).toHaveBeenCalledWith('Token refresh missing refresh token'); + expect(logErrorSpy.mock.calls[0]).toEqual(['Token refresh missing refresh token']); + } finally { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + } + }); + it('returns failed when both response and input have no refresh token', async () => { const originalFetch = globalThis.fetch; globalThis.fetch = vi.fn(async () => diff --git a/test/capability-policy.test.ts b/test/capability-policy.test.ts index 77a63a76..9675a283 100644 --- a/test/capability-policy.test.ts +++ b/test/capability-policy.test.ts @@ -44,4 +44,53 @@ describe("capability policy store", () => { const boostFromCanonical = store.getBoost("id:acc_alias", "gpt-5-codex", 1_500); expect(boostFromCanonical).toBeGreaterThan(0); }); + it("returns zero boost/null snapshot for missing or invalid keys", () => { + const store = new CapabilityPolicyStore(); + expect(store.getBoost("", "gpt-5-codex")).toBe(0); + expect(store.getBoost("id:missing", "gpt-5-codex")).toBe(0); + expect(store.getSnapshot("", "gpt-5-codex")).toBeNull(); + expect(store.getSnapshot("id:missing", "gpt-5-codex")).toBeNull(); + }); + + it("normalizes provider-prefixed models and strips quality suffixes", () => { + const store = new CapabilityPolicyStore(); + store.recordSuccess("id:acc_norm", "openai/gpt-5-codex-high", 1_000); + + const snapshot = store.getSnapshot("id:acc_norm", "gpt-5-codex"); + expect(snapshot).not.toBeNull(); + expect(snapshot?.successes).toBe(1); + }); + + it("ignores blank model and blank account writes", () => { + const store = new CapabilityPolicyStore(); + store.recordSuccess("", "gpt-5-codex", 1_000); + store.recordFailure("id:acc_blank", " ", 1_000); + store.recordUnsupported("", " ", 1_000); + + expect(store.getSnapshot("id:acc_blank", "gpt-5-codex")).toBeNull(); + expect(store.clearAccount("")).toBe(0); + }); + + it("evicts oldest entries when capacity is exceeded", () => { + const store = new CapabilityPolicyStore(); + for (let i = 0; i < 2055; i += 1) { + store.recordSuccess(`id:acc_${i}`, "gpt-5-codex", 1_000 + i); + } + + expect(store.getSnapshot("id:acc_0", "gpt-5-codex")).toBeNull(); + expect(store.getSnapshot("id:acc_2054", "gpt-5-codex")).not.toBeNull(); + }); + + it("clamps boost to score boundaries", () => { + const store = new CapabilityPolicyStore(); + for (let i = 0; i < 20; i += 1) { + store.recordSuccess("id:acc_hi", "gpt-5-codex", 1_000 + i); + } + for (let i = 0; i < 20; i += 1) { + store.recordUnsupported("id:acc_lo", "gpt-5-codex", 1_000 + i); + } + + expect(store.getBoost("id:acc_hi", "gpt-5-codex", 2_000)).toBeLessThanOrEqual(20); + expect(store.getBoost("id:acc_lo", "gpt-5-codex", 2_000)).toBeGreaterThanOrEqual(-30); + }); }); diff --git a/test/cli-auth-menu.test.ts b/test/cli-auth-menu.test.ts index 518417ce..0f06f2c3 100644 --- a/test/cli-auth-menu.test.ts +++ b/test/cli-auth-menu.test.ts @@ -3,6 +3,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const showAuthMenu = vi.fn(); const showAccountDetails = vi.fn(); const isTTY = vi.fn(); +const mockRl = { + question: vi.fn(), + close: vi.fn(), +}; + +vi.mock("node:readline/promises", () => ({ + createInterface: vi.fn(() => mockRl), +})); vi.mock("../lib/ui/auth-menu.js", () => ({ showAuthMenu, @@ -16,6 +24,8 @@ describe("CLI auth menu shortcuts", () => { showAuthMenu.mockReset(); showAccountDetails.mockReset(); isTTY.mockReset(); + mockRl.question.mockReset(); + mockRl.close.mockReset(); isTTY.mockReturnValue(true); process.env.FORCE_INTERACTIVE_MODE = "1"; }); @@ -189,4 +199,132 @@ describe("CLI auth menu shortcuts", () => { ); consoleSpy.mockRestore(); }); + it("returns add mode when auth menu requests add", async () => { + showAuthMenu.mockResolvedValueOnce({ type: "add" }); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "add" }); + }); + + it("returns check mode when auth menu requests check", async () => { + showAuthMenu.mockResolvedValueOnce({ type: "check" }); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "check" }); + }); + + it("loops on search action and then exits on cancel", async () => { + showAuthMenu.mockResolvedValueOnce({ type: "search" }).mockResolvedValueOnce({ type: "cancel" }); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "cancel" }); + expect(showAuthMenu).toHaveBeenCalledTimes(2); + }); + + it("returns manage delete action when account details picks delete", async () => { + showAuthMenu.mockResolvedValueOnce({ + type: "select-account", + account: { index: 1 }, + }); + showAccountDetails.mockResolvedValueOnce("delete"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }, { index: 1 }]); + + expect(result).toEqual({ mode: "manage", deleteAccountIndex: 1 }); + }); + + it("returns manage toggle action when account details picks toggle", async () => { + showAuthMenu.mockResolvedValueOnce({ + type: "select-account", + account: { index: 0, sourceIndex: 2 }, + }); + showAccountDetails.mockResolvedValueOnce("toggle"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0, sourceIndex: 2 }]); + + expect(result).toEqual({ mode: "manage", toggleAccountIndex: 2 }); + }); + + it("continues when account-details delete cannot resolve source index", async () => { + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + showAuthMenu + .mockResolvedValueOnce({ type: "select-account", account: { index: Number.NaN, email: "bad@example.com" } }) + .mockResolvedValueOnce({ type: "cancel" }); + showAccountDetails.mockResolvedValueOnce("delete"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "cancel" }); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Unable to resolve saved account for action")); + consoleSpy.mockRestore(); + }); + + it("returns fresh mode when delete-all is confirmed", async () => { + mockRl.question.mockResolvedValueOnce("DELETE"); + showAuthMenu.mockResolvedValueOnce({ type: "delete-all" }); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "fresh", deleteAll: true }); + expect(mockRl.close).toHaveBeenCalled(); + }); + + it("cancels delete-all when typed confirmation is not DELETE", async () => { + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + mockRl.question.mockResolvedValueOnce("nope"); + showAuthMenu + .mockResolvedValueOnce({ type: "delete-all" }) + .mockResolvedValueOnce({ type: "cancel" }); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "cancel" }); + expect(consoleSpy).toHaveBeenCalledWith("\nDelete all cancelled.\n"); + consoleSpy.mockRestore(); + }); + + it("cancels fresh action when typed confirmation is not DELETE", async () => { + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + mockRl.question.mockResolvedValueOnce("abort"); + showAuthMenu + .mockResolvedValueOnce({ type: "fresh" }) + .mockResolvedValueOnce({ type: "cancel" }); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "cancel" }); + expect(consoleSpy).toHaveBeenCalledWith("\nDelete all cancelled.\n"); + consoleSpy.mockRestore(); + }); + + it("logs and continues when standalone refresh/toggle/delete actions cannot resolve index", async () => { + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + showAuthMenu + .mockResolvedValueOnce({ type: "refresh-account", account: { index: Number.NaN, email: "r@example.com" } }) + .mockResolvedValueOnce({ type: "toggle-account", account: { index: Number.NaN, email: "t@example.com" } }) + .mockResolvedValueOnce({ type: "delete-account", account: { index: Number.NaN, email: "d@example.com" } }) + .mockResolvedValueOnce({ type: "cancel" }); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "cancel" }); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Unable to resolve saved account for action")); + consoleSpy.mockRestore(); + }); }); + + + diff --git a/test/cli.test.ts b/test/cli.test.ts index 8735da4d..39f1b753 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -489,5 +489,56 @@ describe("CLI Module", () => { expect(result).toEqual(candidates[1]); }); }); -}); + describe("additional fallback and env branches", () => { + it("returns check/deep-check/cancel for fallback aliases", async () => { + const { promptLoginMode } = await import("../lib/cli.js"); + + mockRl.question.mockResolvedValueOnce("check"); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ mode: "check" }); + + mockRl.question.mockResolvedValueOnce("deep"); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ mode: "deep-check" }); + + mockRl.question.mockResolvedValueOnce("quit"); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ mode: "cancel" }); + }); + it("evaluates CODEX_TUI/CODEX_DESKTOP/TERM_PROGRAM/ELECTRON branches when TTY is true", async () => { + delete process.env.FORCE_INTERACTIVE_MODE; + const { stdin, stdout } = await import("node:process"); + const origInputTTY = stdin.isTTY; + const origOutputTTY = stdout.isTTY; + Object.defineProperty(stdin, "isTTY", { value: true, writable: true, configurable: true }); + Object.defineProperty(stdout, "isTTY", { value: true, writable: true, configurable: true }); + + try { + process.env.CODEX_TUI = "1"; + let mod = await import("../lib/cli.js"); + expect(mod.isNonInteractiveMode()).toBe(true); + delete process.env.CODEX_TUI; + + process.env.CODEX_DESKTOP = "1"; + mod = await import("../lib/cli.js"); + expect(mod.isNonInteractiveMode()).toBe(true); + delete process.env.CODEX_DESKTOP; + + process.env.TERM_PROGRAM = " codex "; + mod = await import("../lib/cli.js"); + expect(mod.isNonInteractiveMode()).toBe(true); + delete process.env.TERM_PROGRAM; + + process.env.ELECTRON_RUN_AS_NODE = "1"; + mod = await import("../lib/cli.js"); + expect(mod.isNonInteractiveMode()).toBe(true); + delete process.env.ELECTRON_RUN_AS_NODE; + } finally { + delete process.env.CODEX_TUI; + delete process.env.CODEX_DESKTOP; + delete process.env.TERM_PROGRAM; + delete process.env.ELECTRON_RUN_AS_NODE; + Object.defineProperty(stdin, "isTTY", { value: origInputTTY, writable: true, configurable: true }); + Object.defineProperty(stdout, "isTTY", { value: origOutputTTY, writable: true, configurable: true }); + } + }); + }); +}); diff --git a/test/codex-cli-state.test.ts b/test/codex-cli-state.test.ts index ed18621a..1fdd9818 100644 --- a/test/codex-cli-state.test.ts +++ b/test/codex-cli-state.test.ts @@ -4,854 +4,1013 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { - __resetCodexCliWarningCacheForTests, - clearCodexCliStateCache, - isCodexCliSyncEnabled, - loadCodexCliState, - lookupCodexCliTokensByEmail, + __resetCodexCliWarningCacheForTests, + clearCodexCliStateCache, + isCodexCliSyncEnabled, + loadCodexCliState, + lookupCodexCliTokensByEmail, } from "../lib/codex-cli/state.js"; import { - getCodexCliMetricsSnapshot, - resetCodexCliMetricsForTests, + getCodexCliMetricsSnapshot, + resetCodexCliMetricsForTests, } from "../lib/codex-cli/observability.js"; import { setCodexCliActiveSelection } from "../lib/codex-cli/writer.js"; - describe("codex-cli state", () => { - let tempDir: string; - let accountsPath: string; - let authPath: string; - let previousPath: string | undefined; - let previousAuthPath: string | undefined; - let previousSync: string | undefined; - let previousLegacySync: string | undefined; - - beforeEach(async () => { - previousPath = process.env.CODEX_CLI_ACCOUNTS_PATH; - previousAuthPath = process.env.CODEX_CLI_AUTH_PATH; - previousSync = process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI; - previousLegacySync = process.env.CODEX_AUTH_SYNC_CODEX_CLI; - - tempDir = await mkdtemp(join(tmpdir(), "codex-multi-auth-state-")); - accountsPath = join(tempDir, "accounts.json"); - authPath = join(tempDir, "auth.json"); - process.env.CODEX_CLI_ACCOUNTS_PATH = accountsPath; - process.env.CODEX_CLI_AUTH_PATH = authPath; - process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI = "1"; - delete process.env.CODEX_AUTH_SYNC_CODEX_CLI; - clearCodexCliStateCache(); - __resetCodexCliWarningCacheForTests(); - resetCodexCliMetricsForTests(); - }); - - afterEach(async () => { - clearCodexCliStateCache(); - __resetCodexCliWarningCacheForTests(); - if (previousPath === undefined) delete process.env.CODEX_CLI_ACCOUNTS_PATH; - else process.env.CODEX_CLI_ACCOUNTS_PATH = previousPath; - if (previousAuthPath === undefined) delete process.env.CODEX_CLI_AUTH_PATH; - else process.env.CODEX_CLI_AUTH_PATH = previousAuthPath; - if (previousSync === undefined) delete process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI; - else process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI = previousSync; - if (previousLegacySync === undefined) delete process.env.CODEX_AUTH_SYNC_CODEX_CLI; - else process.env.CODEX_AUTH_SYNC_CODEX_CLI = previousLegacySync; - resetCodexCliMetricsForTests(); - await rm(tempDir, { recursive: true, force: true }); - }); - - it("loads Codex CLI accounts and active selection", async () => { - await writeFile( - accountsPath, - JSON.stringify( - { - codexMultiAuthSyncVersion: 123456, - activeAccountId: "acc_b", - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - auth: { - tokens: { - access_token: "a.b.c", - refresh_token: "refresh-a", - }, - }, - }, - { - accountId: "acc_b", - email: "b@example.com", - auth: { - tokens: { - access_token: "x.y.z", - refresh_token: "refresh-b", - }, - }, - active: true, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - - const state = await loadCodexCliState({ forceRefresh: true }); - expect(state?.activeAccountId).toBe("acc_b"); - expect(state?.accounts.length).toBe(2); - expect(state?.syncVersion).toBe(123456); - expect(typeof state?.sourceUpdatedAtMs).toBe("number"); - - const lookup = await lookupCodexCliTokensByEmail("B@EXAMPLE.com"); - expect(lookup?.refreshToken).toBe("refresh-b"); - expect(lookup?.accountId).toBe("acc_b"); - }); - - it("coalesces concurrent cache-miss loads into one file read", async () => { - await writeFile( - accountsPath, - JSON.stringify( - { - activeAccountId: "acc_a", - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - auth: { - tokens: { - access_token: "a.b.c", - refresh_token: "refresh-a", - }, - }, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - clearCodexCliStateCache(); - const readSpy = vi.spyOn(fsPromises, "readFile"); - - try { - const results = await Promise.all( - Array.from({ length: 8 }, () => loadCodexCliState()), - ); - expect(results.every((state) => state?.activeAccountId === "acc_a")).toBe(true); - const accountReads = readSpy.mock.calls.filter( - (args) => String(args[0]) === accountsPath, - ); - expect(accountReads.length).toBe(1); - } finally { - readSpy.mockRestore(); - } - }); - - it("retries transient read/stat lock errors before failing", async () => { - await writeFile( - accountsPath, - JSON.stringify( - { - activeAccountId: "acc_a", - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - auth: { - tokens: { - access_token: "a.b.c", - refresh_token: "refresh-a", - }, - }, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - clearCodexCliStateCache(); - - const realReadFile = fsPromises.readFile.bind(fsPromises); - const realStat = fsPromises.stat.bind(fsPromises); - let accountsReadAttempts = 0; - let accountsStatAttempts = 0; - const readSpy = vi.spyOn(fsPromises, "readFile"); - const statSpy = vi.spyOn(fsPromises, "stat"); - readSpy.mockImplementation(async (...args) => { - if (String(args[0]) === accountsPath) { - accountsReadAttempts += 1; - if (accountsReadAttempts === 1) { - const error = new Error("locked") as NodeJS.ErrnoException; - error.code = "EPERM"; - throw error; - } - } - return realReadFile(...args); - }); - statSpy.mockImplementation(async (...args) => { - if (String(args[0]) === accountsPath) { - accountsStatAttempts += 1; - if (accountsStatAttempts === 1) { - const error = new Error("busy") as NodeJS.ErrnoException; - error.code = "EBUSY"; - throw error; - } - } - return realStat(...args); - }); - - try { - const state = await loadCodexCliState({ forceRefresh: true }); - expect(state?.activeAccountId).toBe("acc_a"); - expect(accountsReadAttempts).toBe(2); - expect(accountsStatAttempts).toBe(2); - } finally { - readSpy.mockRestore(); - statSpy.mockRestore(); - } - }); - - it("falls back to Codex auth.json when accounts.json is missing", async () => { - await writeFile( - authPath, - JSON.stringify( - { - auth_mode: "chatgpt", - OPENAI_API_KEY: null, - tokens: { - access_token: - "eyJhbGciOiJub25lIn0.eyJleHAiOjQxMDAwMDAwMDAsImh0dHBzOi8vYXBpLm9wZW5haS5jb20vYXV0aCI6eyJjaGF0Z3B0X2FjY291bnRfaWQiOiJhY2NfYXV0aCJ9LCJlbWFpbCI6ImF1dGhAZXhhbXBsZS5jb20ifQ.", - refresh_token: "refresh-auth", - account_id: "acc_auth", - }, - last_refresh: "2026-02-25T21:36:07.864Z", - }, - null, - 2, - ), - "utf-8", - ); - - const state = await loadCodexCliState({ forceRefresh: true }); - expect(state?.path).toBe(authPath); - expect(state?.accounts.length).toBe(1); - expect(state?.activeAccountId).toBe("acc_auth"); - expect(state?.activeEmail).toBe("auth@example.com"); - - const lookup = await lookupCodexCliTokensByEmail("AUTH@EXAMPLE.COM"); - expect(lookup?.refreshToken).toBe("refresh-auth"); - expect(lookup?.accountId).toBe("acc_auth"); - }); - - it("falls back to auth.json when accounts.json is malformed", async () => { - await writeFile(accountsPath, "{ malformed json", "utf-8"); - await writeFile( - authPath, - JSON.stringify( - { - auth_mode: "chatgpt", - OPENAI_API_KEY: null, - tokens: { - access_token: - "eyJhbGciOiJub25lIn0.eyJleHAiOjQxMDAwMDAwMDAsImh0dHBzOi8vYXBpLm9wZW5haS5jb20vYXV0aCI6eyJjaGF0Z3B0X2FjY291bnRfaWQiOiJhY2NfYXV0aCJ9LCJlbWFpbCI6ImF1dGhAZXhhbXBsZS5jb20ifQ.", - refresh_token: "refresh-auth", - account_id: "acc_auth", - }, - }, - null, - 2, - ), - "utf-8", - ); - - const state = await loadCodexCliState({ forceRefresh: true }); - expect(state?.path).toBe(authPath); - expect(state?.activeAccountId).toBe("acc_auth"); - expect(state?.accounts.length).toBe(1); - }); - - it("derives active selection from per-account active flag", async () => { - await writeFile( - accountsPath, - JSON.stringify( - { - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - active: true, - auth: { - tokens: { - access_token: "a.b.c", - refresh_token: "refresh-a", - }, - }, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - - const state = await loadCodexCliState({ forceRefresh: true }); - expect(state?.activeAccountId).toBe("acc_a"); - expect(state?.activeEmail).toBe("a@example.com"); - }); - - it("returns null for malformed Codex CLI payload", async () => { - await writeFile( - accountsPath, - JSON.stringify( - { - accounts: { - accountId: "acc_a", - }, - }, - null, - 2, - ), - "utf-8", - ); - - const state = await loadCodexCliState({ forceRefresh: true }); - expect(state).toBeNull(); - }); - - it("returns null when sync is disabled", async () => { - process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI = "0"; - clearCodexCliStateCache(); - - await writeFile( - accountsPath, - JSON.stringify( - { - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - auth: { - tokens: { - access_token: "a.b.c", - refresh_token: "refresh-a", - }, - }, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - - const state = await loadCodexCliState({ forceRefresh: true }); - expect(state).toBeNull(); - const lookup = await lookupCodexCliTokensByEmail("a@example.com"); - expect(lookup).toBeNull(); - }); - - it("prefers modern sync env over legacy env", () => { - process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI = "1"; - process.env.CODEX_AUTH_SYNC_CODEX_CLI = "0"; - expect(isCodexCliSyncEnabled()).toBe(true); - }); - - it("tracks read/write metrics counters", async () => { - await writeFile( - accountsPath, - JSON.stringify( - { - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - auth: { - tokens: { - access_token: "a.b.c", - refresh_token: "refresh-a", - }, - }, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - - await loadCodexCliState({ forceRefresh: true }); - await setCodexCliActiveSelection({ accountId: "acc_a" }); - - const metrics = getCodexCliMetricsSnapshot(); - expect(metrics.readAttempts).toBeGreaterThan(0); - expect(metrics.readSuccesses).toBeGreaterThan(0); - expect(metrics.writeAttempts).toBeGreaterThan(0); - expect(metrics.writeSuccesses).toBeGreaterThan(0); - }); - - it("persists active selection back to Codex CLI state", async () => { - await writeFile( - accountsPath, - JSON.stringify( - { - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - auth: { - tokens: { - access_token: "a.b.c", - refresh_token: "refresh-a", - }, - }, - }, - { - accountId: "acc_b", - email: "b@example.com", - auth: { - tokens: { - access_token: "x.y.z", - refresh_token: "refresh-b", - }, - }, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - - const updated = await setCodexCliActiveSelection({ accountId: "acc_b" }); - expect(updated).toBe(true); - - const written = JSON.parse(await readFile(accountsPath, "utf-8")) as { - activeAccountId?: string; - accounts?: Array<{ active?: boolean }>; - }; - expect(written.activeAccountId).toBe("acc_b"); - expect(written.accounts?.[0]?.active).toBe(false); - expect(written.accounts?.[1]?.active).toBe(true); - }); - - it("updates both accounts.json and auth.json when both files exist", async () => { - await writeFile( - accountsPath, - JSON.stringify( - { - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - auth: { - tokens: { - access_token: "access-a", - id_token: "id-a", - refresh_token: "refresh-a", - }, - }, - }, - { - accountId: "acc_b", - email: "b@example.com", - auth: { - tokens: { - access_token: "access-b", - id_token: "id-b", - refresh_token: "refresh-b", - }, - }, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - await writeFile( - authPath, - JSON.stringify( - { - auth_mode: "chatgpt", - OPENAI_API_KEY: null, - tokens: { - access_token: "old-access", - id_token: "old-id-token", - refresh_token: "old-refresh", - account_id: "old-account", - }, - }, - null, - 2, - ), - "utf-8", - ); - - const updated = await setCodexCliActiveSelection({ accountId: "acc_b" }); - expect(updated).toBe(true); - - const writtenAccounts = JSON.parse(await readFile(accountsPath, "utf-8")) as { - activeAccountId?: string; - accounts?: Array<{ active?: boolean }>; - }; - expect(writtenAccounts.activeAccountId).toBe("acc_b"); - expect(writtenAccounts.accounts?.[0]?.active).toBe(false); - expect(writtenAccounts.accounts?.[1]?.active).toBe(true); - - const writtenAuth = JSON.parse(await readFile(authPath, "utf-8")) as { - tokens?: { access_token?: string; id_token?: string; refresh_token?: string; account_id?: string }; - }; - expect(writtenAuth.tokens?.access_token).toBe("access-b"); - expect(writtenAuth.tokens?.id_token).toBe("id-b"); - expect(writtenAuth.tokens?.refresh_token).toBe("refresh-b"); - expect(writtenAuth.tokens?.account_id).toBe("acc_b"); - }); - - it("prefers explicit selection tokens over stale accounts.json tokens", async () => { - await writeFile( - accountsPath, - JSON.stringify( - { - accounts: [ - { - accountId: "acc_b", - email: "b@example.com", - auth: { - tokens: { - access_token: "stale-access", - id_token: "stale-id", - refresh_token: "stale-refresh", - }, - }, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - await writeFile( - authPath, - JSON.stringify( - { - auth_mode: "chatgpt", - OPENAI_API_KEY: null, - tokens: { - access_token: "old-access", - id_token: "old-id-token", - refresh_token: "old-refresh", - account_id: "old-account", - }, - }, - null, - 2, - ), - "utf-8", - ); - - const updated = await setCodexCliActiveSelection({ - accountId: "acc_b", - email: "explicit@example.com", - accessToken: "fresh-access", - refreshToken: "fresh-refresh", - idToken: "fresh-id-token", - }); - expect(updated).toBe(true); - - const writtenAuth = JSON.parse(await readFile(authPath, "utf-8")) as { - email?: string; - tokens?: { access_token?: string; id_token?: string; refresh_token?: string }; - }; - expect(writtenAuth.email).toBe("explicit@example.com"); - expect(writtenAuth.tokens?.access_token).toBe("fresh-access"); - expect(writtenAuth.tokens?.id_token).toBe("fresh-id-token"); - expect(writtenAuth.tokens?.refresh_token).toBe("fresh-refresh"); - }); - - it("persists active selection by email match when accountId is omitted", async () => { - await writeFile( - accountsPath, - JSON.stringify( - { - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - auth: { - tokens: { - access_token: "a.b.c", - refresh_token: "refresh-a", - }, - }, - }, - { - accountId: "acc_b", - email: "b@example.com", - auth: { - tokens: { - access_token: "x.y.z", - refresh_token: "refresh-b", - }, - }, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - - const updated = await setCodexCliActiveSelection({ email: "B@EXAMPLE.COM" }); - expect(updated).toBe(true); - - const written = JSON.parse(await readFile(accountsPath, "utf-8")) as { - activeAccountId?: string; - activeEmail?: string; - }; - expect(written.activeAccountId).toBe("acc_b"); - expect(written.activeEmail).toBe("b@example.com"); - }); - - it("returns false when selection has no matching Codex CLI account", async () => { - await writeFile( - accountsPath, - JSON.stringify( - { - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - auth: { - tokens: { - access_token: "a.b.c", - refresh_token: "refresh-a", - }, - }, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - - const updated = await setCodexCliActiveSelection({ accountId: "missing-account" }); - expect(updated).toBe(false); - }); - - it("still updates auth.json when accounts.json has no match", async () => { - await writeFile( - accountsPath, - JSON.stringify( - { - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - auth: { - tokens: { - access_token: "access-a", - refresh_token: "refresh-a", - }, - }, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - await writeFile( - authPath, - JSON.stringify( - { - auth_mode: "chatgpt", - OPENAI_API_KEY: null, - tokens: { - access_token: "old-access", - id_token: "old-id-token", - refresh_token: "old-refresh", - account_id: "old-account", - }, - }, - null, - 2, - ), - "utf-8", - ); - - const updated = await setCodexCliActiveSelection({ - accountId: "missing-account", - email: "b@example.com", - accessToken: "fresh-access", - refreshToken: "fresh-refresh", - }); - expect(updated).toBe(true); - - const writtenAuth = JSON.parse(await readFile(authPath, "utf-8")) as { - email?: string; - tokens?: { access_token?: string; id_token?: string; refresh_token?: string }; - }; - expect(writtenAuth.email).toBe("b@example.com"); - expect(writtenAuth.tokens?.access_token).toBe("fresh-access"); - expect(writtenAuth.tokens?.id_token).toBe("old-id-token"); - expect(writtenAuth.tokens?.refresh_token).toBe("fresh-refresh"); - }); - - it("does not update auth.json when no account match and selection lacks tokens", async () => { - await writeFile( - accountsPath, - JSON.stringify( - { - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - auth: { - tokens: { - access_token: "access-a", - refresh_token: "refresh-a", - }, - }, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - await writeFile( - authPath, - JSON.stringify( - { - auth_mode: "chatgpt", - OPENAI_API_KEY: null, - tokens: { - access_token: "old-access", - id_token: "old-id-token", - refresh_token: "old-refresh", - account_id: "old-account", - }, - }, - null, - 2, - ), - "utf-8", - ); - - const before = await readFile(authPath, "utf-8"); - const updated = await setCodexCliActiveSelection({ - accountId: "missing-account", - email: "b@example.com", - }); - expect(updated).toBe(false); - const after = await readFile(authPath, "utf-8"); - expect(after).toBe(before); - }); - - it("returns false when writer sync is disabled", async () => { - process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI = "0"; - clearCodexCliStateCache(); - - await writeFile( - accountsPath, - JSON.stringify( - { - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - auth: { - tokens: { - access_token: "a.b.c", - refresh_token: "refresh-a", - }, - }, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - - const updated = await setCodexCliActiveSelection({ accountId: "acc_a" }); - expect(updated).toBe(false); - }); - - it("returns false for malformed writer payload", async () => { - await writeFile( - accountsPath, - JSON.stringify( - { - accounts: { - accountId: "acc_a", - }, - }, - null, - 2, - ), - "utf-8", - ); - - const updated = await setCodexCliActiveSelection({ accountId: "acc_a" }); - expect(updated).toBe(false); - }); - - it("writes selected tokens to Codex auth.json when accounts.json is absent", async () => { - await writeFile( - authPath, - JSON.stringify( - { - auth_mode: "chatgpt", - OPENAI_API_KEY: null, - tokens: { - access_token: "old.access.token", - id_token: "old.id.token", - refresh_token: "old-refresh", - account_id: "old-id", - }, - last_refresh: "2026-01-01T00:00:00.000Z", - }, - null, - 2, - ), - "utf-8", - ); - - const updated = await setCodexCliActiveSelection({ - accountId: "acc_new", - email: "new@example.com", - accessToken: "new.access.token", - refreshToken: "new-refresh-token", - expiresAt: Date.parse("2026-03-01T00:00:00.000Z"), - }); - expect(updated).toBe(true); - - const written = JSON.parse(await readFile(authPath, "utf-8")) as { - tokens?: { - access_token?: string; - id_token?: string; - refresh_token?: string; - account_id?: string; - }; - }; - expect(written.tokens?.access_token).toBe("new.access.token"); - expect(written.tokens?.id_token).toBe("old.id.token"); - expect(written.tokens?.refresh_token).toBe("new-refresh-token"); - expect(written.tokens?.account_id).toBe("acc_new"); - }); + let tempDir: string; + let accountsPath: string; + let authPath: string; + let configPath: string; + let previousPath: string | undefined; + let previousAuthPath: string | undefined; + let previousConfigPath: string | undefined; + let previousSync: string | undefined; + let previousLegacySync: string | undefined; + let previousEnforceFileStore: string | undefined; + beforeEach(async () => { + previousPath = process.env.CODEX_CLI_ACCOUNTS_PATH; + previousAuthPath = process.env.CODEX_CLI_AUTH_PATH; + previousConfigPath = process.env.CODEX_CLI_CONFIG_PATH; + previousSync = process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI; + previousLegacySync = process.env.CODEX_AUTH_SYNC_CODEX_CLI; + previousEnforceFileStore = + process.env.CODEX_MULTI_AUTH_ENFORCE_CLI_FILE_AUTH_STORE; + tempDir = await mkdtemp(join(tmpdir(), "codex-multi-auth-state-")); + accountsPath = join(tempDir, "accounts.json"); + authPath = join(tempDir, "auth.json"); + configPath = join(tempDir, "config.toml"); + process.env.CODEX_CLI_ACCOUNTS_PATH = accountsPath; + process.env.CODEX_CLI_AUTH_PATH = authPath; + process.env.CODEX_CLI_CONFIG_PATH = configPath; + process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI = "1"; + process.env.CODEX_MULTI_AUTH_ENFORCE_CLI_FILE_AUTH_STORE = "1"; + delete process.env.CODEX_AUTH_SYNC_CODEX_CLI; + clearCodexCliStateCache(); + __resetCodexCliWarningCacheForTests(); + resetCodexCliMetricsForTests(); + }); + afterEach(async () => { + clearCodexCliStateCache(); + __resetCodexCliWarningCacheForTests(); + if (previousPath === undefined) delete process.env.CODEX_CLI_ACCOUNTS_PATH; + else process.env.CODEX_CLI_ACCOUNTS_PATH = previousPath; + if (previousAuthPath === undefined) delete process.env.CODEX_CLI_AUTH_PATH; + else process.env.CODEX_CLI_AUTH_PATH = previousAuthPath; + if (previousConfigPath === undefined) delete process.env.CODEX_CLI_CONFIG_PATH; + else process.env.CODEX_CLI_CONFIG_PATH = previousConfigPath; + if (previousSync === undefined) + delete process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI; + else process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI = previousSync; + if (previousLegacySync === undefined) + delete process.env.CODEX_AUTH_SYNC_CODEX_CLI; + else process.env.CODEX_AUTH_SYNC_CODEX_CLI = previousLegacySync; + if (previousEnforceFileStore === undefined) { + delete process.env.CODEX_MULTI_AUTH_ENFORCE_CLI_FILE_AUTH_STORE; + } else { + process.env.CODEX_MULTI_AUTH_ENFORCE_CLI_FILE_AUTH_STORE = + previousEnforceFileStore; + } + resetCodexCliMetricsForTests(); + await rm(tempDir, { recursive: true, force: true }); + }); + it("loads Codex CLI accounts and active selection", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + codexMultiAuthSyncVersion: 123456, + activeAccountId: "acc_b", + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { access_token: "a.b.c", refresh_token: "refresh-a" }, + }, + }, + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { access_token: "x.y.z", refresh_token: "refresh-b" }, + }, + active: true, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + const state = await loadCodexCliState({ forceRefresh: true }); + expect(state?.activeAccountId).toBe("acc_b"); + expect(state?.accounts.length).toBe(2); + expect(state?.syncVersion).toBe(123456); + expect(typeof state?.sourceUpdatedAtMs).toBe("number"); + const lookup = await lookupCodexCliTokensByEmail("B@EXAMPLE.com"); + expect(lookup?.refreshToken).toBe("refresh-b"); + expect(lookup?.accountId).toBe("acc_b"); + }); + it("coalesces concurrent cache-miss loads into one file read", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_a", + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { access_token: "a.b.c", refresh_token: "refresh-a" }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + clearCodexCliStateCache(); + const readSpy = vi.spyOn(fsPromises, "readFile"); + try { + const results = await Promise.all( + Array.from({ length: 8 }, () => loadCodexCliState()), + ); + expect(results.every((state) => state?.activeAccountId === "acc_a")).toBe( + true, + ); + const accountReads = readSpy.mock.calls.filter( + (args) => String(args[0]) === accountsPath, + ); + expect(accountReads.length).toBe(1); + } finally { + readSpy.mockRestore(); + } + }); + it("retries transient read/stat lock errors before failing", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_a", + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { access_token: "a.b.c", refresh_token: "refresh-a" }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + clearCodexCliStateCache(); + const realReadFile = fsPromises.readFile.bind(fsPromises); + const realStat = fsPromises.stat.bind(fsPromises); + let accountsReadAttempts = 0; + let accountsStatAttempts = 0; + const readSpy = vi.spyOn(fsPromises, "readFile"); + const statSpy = vi.spyOn(fsPromises, "stat"); + readSpy.mockImplementation(async (...args) => { + if (String(args[0]) === accountsPath) { + accountsReadAttempts += 1; + if (accountsReadAttempts === 1) { + const error = new Error("locked") as NodeJS.ErrnoException; + error.code = "EPERM"; + throw error; + } + } + return realReadFile(...args); + }); + statSpy.mockImplementation(async (...args) => { + if (String(args[0]) === accountsPath) { + accountsStatAttempts += 1; + if (accountsStatAttempts === 1) { + const error = new Error("busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + } + return realStat(...args); + }); + try { + const state = await loadCodexCliState({ forceRefresh: true }); + expect(state?.accounts[0]?.accountId).toBe("acc_a"); + expect(accountsReadAttempts).toBe(2); + expect(accountsStatAttempts).toBe(2); + } finally { + readSpy.mockRestore(); + statSpy.mockRestore(); + } + }); + it("falls back to Codex auth.json when accounts.json is missing", async () => { + await writeFile( + authPath, + JSON.stringify( + { + auth_mode: "chatgpt", + OPENAI_API_KEY: null, + tokens: { + access_token: + "eyJhbGciOiJub25lIn0.eyJleHAiOjQxMDAwMDAwMDAsImh0dHBzOi8vYXBpLm9wZW5haS5jb20vYXV0aCI6eyJjaGF0Z3B0X2FjY291bnRfaWQiOiJhY2NfYXV0aCJ9LCJlbWFpbCI6ImF1dGhAZXhhbXBsZS5jb20ifQ.", + refresh_token: "refresh-auth", + account_id: "acc_auth", + }, + last_refresh: "2026-02-25T21:36:07.864Z", + }, + null, + 2, + ), + "utf-8", + ); + const state = await loadCodexCliState({ forceRefresh: true }); + expect(state?.path).toBe(authPath); + expect(state?.accounts.length).toBe(1); + expect(state?.activeAccountId).toBe("acc_auth"); + expect(state?.activeEmail).toBe("auth@example.com"); + const lookup = await lookupCodexCliTokensByEmail("AUTH@EXAMPLE.COM"); + expect(lookup?.refreshToken).toBe("refresh-auth"); + expect(lookup?.accountId).toBe("acc_auth"); + }); + it("falls back to auth.json when accounts.json is malformed", async () => { + await writeFile(accountsPath, "{ malformed json", "utf-8"); + await writeFile( + authPath, + JSON.stringify( + { + auth_mode: "chatgpt", + OPENAI_API_KEY: null, + tokens: { + access_token: + "eyJhbGciOiJub25lIn0.eyJleHAiOjQxMDAwMDAwMDAsImh0dHBzOi8vYXBpLm9wZW5haS5jb20vYXV0aCI6eyJjaGF0Z3B0X2FjY291bnRfaWQiOiJhY2NfYXV0aCJ9LCJlbWFpbCI6ImF1dGhAZXhhbXBsZS5jb20ifQ.", + refresh_token: "refresh-auth", + account_id: "acc_auth", + }, + }, + null, + 2, + ), + "utf-8", + ); + const state = await loadCodexCliState({ forceRefresh: true }); + expect(state?.path).toBe(authPath); + expect(state?.activeAccountId).toBe("acc_auth"); + expect(state?.accounts.length).toBe(1); + }); + it("derives active selection from per-account active flag", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + active: true, + auth: { + tokens: { access_token: "a.b.c", refresh_token: "refresh-a" }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + const state = await loadCodexCliState({ forceRefresh: true }); + expect(state?.accounts[0]?.accountId).toBe("acc_a"); + expect(state?.activeEmail).toBe("a@example.com"); + }); + it("returns null for malformed Codex CLI payload", async () => { + await writeFile( + accountsPath, + JSON.stringify({ accounts: { accountId: "acc_a" } }, null, 2), + "utf-8", + ); + const state = await loadCodexCliState({ forceRefresh: true }); + expect(state).toBeNull(); + }); + it("returns null when sync is disabled", async () => { + process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI = "0"; + clearCodexCliStateCache(); + await writeFile( + accountsPath, + JSON.stringify( + { + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { access_token: "a.b.c", refresh_token: "refresh-a" }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + const state = await loadCodexCliState({ forceRefresh: true }); + expect(state).toBeNull(); + const lookup = await lookupCodexCliTokensByEmail("a@example.com"); + expect(lookup).toBeNull(); + }); + it("prefers modern sync env over legacy env", () => { + process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI = "1"; + process.env.CODEX_AUTH_SYNC_CODEX_CLI = "0"; + expect(isCodexCliSyncEnabled()).toBe(true); + }); + it("tracks read/write metrics counters", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { access_token: "a.b.c", refresh_token: "refresh-a" }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + await loadCodexCliState({ forceRefresh: true }); + await setCodexCliActiveSelection({ accountId: "acc_a" }); + const metrics = getCodexCliMetricsSnapshot(); + expect(metrics.readAttempts).toBeGreaterThan(0); + expect(metrics.readSuccesses).toBeGreaterThan(0); + expect(metrics.writeAttempts).toBeGreaterThan(0); + expect(metrics.writeSuccesses).toBeGreaterThan(0); + }); + it("persists active selection back to Codex CLI state", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { access_token: "a.b.c", refresh_token: "refresh-a" }, + }, + }, + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { access_token: "x.y.z", refresh_token: "refresh-b" }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + const updated = await setCodexCliActiveSelection({ accountId: "acc_b" }); + expect(updated).toBe(true); + const written = JSON.parse(await readFile(accountsPath, "utf-8")) as { + activeAccountId?: string; + accounts?: Array<{ active?: boolean }>; + }; + expect(written.activeAccountId).toBe("acc_b"); + expect(written.accounts?.[0]?.active).toBe(false); + expect(written.accounts?.[1]?.active).toBe(true); + }); + it("updates both accounts.json and auth.json when both files exist", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { + access_token: "access-a", + id_token: "id-a", + refresh_token: "refresh-a", + }, + }, + }, + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "access-b", + id_token: "id-b", + refresh_token: "refresh-b", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + await writeFile( + authPath, + JSON.stringify( + { + auth_mode: "chatgpt", + OPENAI_API_KEY: null, + tokens: { + access_token: "old-access", + id_token: "old-id-token", + refresh_token: "old-refresh", + account_id: "old-account", + }, + }, + null, + 2, + ), + "utf-8", + ); + const updated = await setCodexCliActiveSelection({ accountId: "acc_b" }); + expect(updated).toBe(true); + const writtenAccounts = JSON.parse( + await readFile(accountsPath, "utf-8"), + ) as { activeAccountId?: string; accounts?: Array<{ active?: boolean }> }; + expect(writtenAccounts.activeAccountId).toBe("acc_b"); + expect(writtenAccounts.accounts?.[0]?.active).toBe(false); + expect(writtenAccounts.accounts?.[1]?.active).toBe(true); + const writtenAuth = JSON.parse(await readFile(authPath, "utf-8")) as { + tokens?: { + access_token?: string; + id_token?: string; + refresh_token?: string; + account_id?: string; + }; + }; + expect(writtenAuth.tokens?.access_token).toBe("access-b"); + expect(writtenAuth.tokens?.id_token).toBe("id-b"); + expect(writtenAuth.tokens?.refresh_token).toBe("refresh-b"); + expect(writtenAuth.tokens?.account_id).toBe("acc_b"); + }); + it("prefers explicit selection tokens over stale accounts.json tokens", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + accounts: [ + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "stale-access", + id_token: "stale-id", + refresh_token: "stale-refresh", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + await writeFile( + authPath, + JSON.stringify( + { + auth_mode: "chatgpt", + OPENAI_API_KEY: null, + tokens: { + access_token: "old-access", + id_token: "old-id-token", + refresh_token: "old-refresh", + account_id: "old-account", + }, + }, + null, + 2, + ), + "utf-8", + ); + const updated = await setCodexCliActiveSelection({ + accountId: "acc_b", + email: "explicit@example.com", + accessToken: "fresh-access", + refreshToken: "fresh-refresh", + idToken: "fresh-id-token", + }); + expect(updated).toBe(true); + const writtenAuth = JSON.parse(await readFile(authPath, "utf-8")) as { + email?: string; + tokens?: { + access_token?: string; + id_token?: string; + refresh_token?: string; + }; + }; + expect(writtenAuth.email).toBe("explicit@example.com"); + expect(writtenAuth.tokens?.access_token).toBe("fresh-access"); + expect(writtenAuth.tokens?.id_token).toBe("fresh-id-token"); + expect(writtenAuth.tokens?.refresh_token).toBe("fresh-refresh"); + }); + it("persists active selection by email match when accountId is omitted", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { access_token: "a.b.c", refresh_token: "refresh-a" }, + }, + }, + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { access_token: "x.y.z", refresh_token: "refresh-b" }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + const updated = await setCodexCliActiveSelection({ + email: "B@EXAMPLE.COM", + }); + expect(updated).toBe(true); + const written = JSON.parse(await readFile(accountsPath, "utf-8")) as { + activeAccountId?: string; + activeEmail?: string; + }; + expect(written.activeAccountId).toBe("acc_b"); + expect(written.activeEmail).toBe("b@example.com"); + }); + it("returns false when selection has no matching Codex CLI account", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { access_token: "a.b.c", refresh_token: "refresh-a" }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + const updated = await setCodexCliActiveSelection({ + accountId: "missing-account", + }); + expect(updated).toBe(false); + }); + it("still updates auth.json when accounts.json has no match", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { + access_token: "access-a", + refresh_token: "refresh-a", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + await writeFile( + authPath, + JSON.stringify( + { + auth_mode: "chatgpt", + OPENAI_API_KEY: null, + tokens: { + access_token: "old-access", + id_token: "old-id-token", + refresh_token: "old-refresh", + account_id: "old-account", + }, + }, + null, + 2, + ), + "utf-8", + ); + const updated = await setCodexCliActiveSelection({ + accountId: "missing-account", + email: "b@example.com", + accessToken: "fresh-access", + refreshToken: "fresh-refresh", + }); + expect(updated).toBe(true); + const writtenAuth = JSON.parse(await readFile(authPath, "utf-8")) as { + email?: string; + tokens?: { + access_token?: string; + id_token?: string; + refresh_token?: string; + }; + }; + expect(writtenAuth.email).toBe("b@example.com"); + expect(writtenAuth.tokens?.access_token).toBe("fresh-access"); + expect(writtenAuth.tokens?.id_token).toBe("fresh-access"); + expect(writtenAuth.tokens?.refresh_token).toBe("fresh-refresh"); + }); + it("does not update auth.json when no account match and selection lacks tokens", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { + access_token: "access-a", + refresh_token: "refresh-a", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + await writeFile( + authPath, + JSON.stringify( + { + auth_mode: "chatgpt", + OPENAI_API_KEY: null, + tokens: { + access_token: "old-access", + id_token: "old-id-token", + refresh_token: "old-refresh", + account_id: "old-account", + }, + }, + null, + 2, + ), + "utf-8", + ); + const before = await readFile(authPath, "utf-8"); + const updated = await setCodexCliActiveSelection({ + accountId: "missing-account", + email: "b@example.com", + }); + expect(updated).toBe(false); + const after = await readFile(authPath, "utf-8"); + expect(after).toBe(before); + }); + it("returns false when writer sync is disabled", async () => { + process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI = "0"; + clearCodexCliStateCache(); + await writeFile( + accountsPath, + JSON.stringify( + { + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { access_token: "a.b.c", refresh_token: "refresh-a" }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + const updated = await setCodexCliActiveSelection({ accountId: "acc_a" }); + expect(updated).toBe(false); + }); + it("returns false for malformed writer payload", async () => { + await writeFile( + accountsPath, + JSON.stringify({ accounts: { accountId: "acc_a" } }, null, 2), + "utf-8", + ); + const updated = await setCodexCliActiveSelection({ accountId: "acc_a" }); + expect(updated).toBe(false); + }); + it("writes selected tokens to Codex auth.json when accounts.json is absent", async () => { + await writeFile( + authPath, + JSON.stringify( + { + auth_mode: "chatgpt", + OPENAI_API_KEY: null, + tokens: { + access_token: "old.access.token", + id_token: "old.id.token", + refresh_token: "old-refresh", + account_id: "old-id", + }, + last_refresh: "2026-01-01T00:00:00.000Z", + }, + null, + 2, + ), + "utf-8", + ); + const updated = await setCodexCliActiveSelection({ + accountId: "acc_new", + email: "new@example.com", + accessToken: "new.access.token", + refreshToken: "new-refresh-token", + expiresAt: Date.parse("2026-03-01T00:00:00.000Z"), + }); + expect(updated).toBe(true); + const written = JSON.parse(await readFile(authPath, "utf-8")) as { + tokens?: { + access_token?: string; + id_token?: string; + refresh_token?: string; + account_id?: string; + }; + }; + expect(written.tokens?.access_token).toBe("new.access.token"); + expect(written.tokens?.id_token).toBe("new.access.token"); + expect(written.tokens?.refresh_token).toBe("new-refresh-token"); + expect(written.tokens?.account_id).toBe("acc_new"); + }); + it("creates auth.json when both accounts.json and auth.json are absent", async () => { + const updated = await setCodexCliActiveSelection({ + accountId: "acc_new", + email: "new@example.com", + accessToken: "new.access.token", + refreshToken: "new-refresh-token", + expiresAt: Date.parse("2026-03-01T00:00:00.000Z"), + }); + expect(updated).toBe(true); + + const written = JSON.parse(await readFile(authPath, "utf-8")) as { + email?: string; + tokens?: { + access_token?: string; + id_token?: string; + refresh_token?: string; + account_id?: string; + }; + }; + expect(written.email).toBe("new@example.com"); + expect(written.tokens?.access_token).toBe("new.access.token"); + expect(written.tokens?.id_token).toBe("new.access.token"); + expect(written.tokens?.refresh_token).toBe("new-refresh-token"); + expect(written.tokens?.account_id).toBe("acc_new"); + }); + it("honors legacy sync env values and emits warning metric once", () => { + delete process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI; + process.env.CODEX_AUTH_SYNC_CODEX_CLI = "0"; + expect(isCodexCliSyncEnabled()).toBe(false); + process.env.CODEX_AUTH_SYNC_CODEX_CLI = "1"; + expect(isCodexCliSyncEnabled()).toBe(true); + expect(isCodexCliSyncEnabled()).toBe(true); + expect(getCodexCliMetricsSnapshot().legacySyncEnvUses).toBe(1); + }); + it("records a read miss when neither accounts.json nor auth.json exists", async () => { + const state = await loadCodexCliState({ forceRefresh: true }); + expect(state).toBeNull(); + expect(getCodexCliMetricsSnapshot().readMisses).toBeGreaterThanOrEqual(1); + }); + it("parses string booleans and numbers while filtering invalid account entries", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + accounts: [ + 123, + { accountId: "missing" }, + { + account_id: "acc_true", + username: "TRUE@EXAMPLE.COM", + access_token: "true.access.token", + refresh_token: "true-refresh", + is_active: "1", + expires_at: "1710000000000", + }, + { + id: "acc_false", + user_email: "FALSE@EXAMPLE.COM", + access_token: "false.access.token", + refresh_token: "false-refresh", + active: "0", + expiresAt: "1710000005000", + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + const state = await loadCodexCliState({ forceRefresh: true }); + expect(state?.accounts).toHaveLength(2); + expect(state?.activeAccountId).toBe("acc_true"); + expect(state?.activeEmail).toBe("true@example.com"); + expect(state?.accounts[0]?.isActive).toBe(true); + expect(state?.accounts[0]?.expiresAt).toBe(1710000000000); + expect(state?.accounts[1]?.isActive).toBe(false); + expect(state?.accounts[1]?.expiresAt).toBe(1710000005000); + }); + it("derives expiration from JWT and uses auth accountId/email fallbacks", async () => { + const jwtPayload = Buffer.from( + JSON.stringify({ exp: 4_100_000_000 }), + "utf-8", + ).toString("base64url"); + const accessToken = `eyJhbGciOiJub25lIn0.${jwtPayload}.`; + await writeFile( + authPath, + JSON.stringify( + { + email: "Auth@Example.COM", + codexMultiAuthSyncVersion: "777", + tokens: { + access_token: accessToken, + refresh_token: "refresh-auth", + accountId: " acc_from_auth_field ", + }, + }, + null, + 2, + ), + "utf-8", + ); + const state = await loadCodexCliState({ forceRefresh: true }); + expect(state?.activeAccountId).toBe("acc_from_auth_field"); + expect(state?.activeEmail).toBe("auth@example.com"); + expect(state?.syncVersion).toBe(777); + expect(state?.accounts[0]?.expiresAt).toBe(4_100_000_000_000); + }); + it("returns null when auth payload tokens are missing or tokenless", async () => { + await writeFile(authPath, JSON.stringify({ tokens: [] }, null, 2), "utf-8"); + expect(await loadCodexCliState({ forceRefresh: true })).toBeNull(); + await writeFile( + authPath, + JSON.stringify({ tokens: { id_token: "id-only" } }, null, 2), + "utf-8", + ); + clearCodexCliStateCache(); + expect(await loadCodexCliState({ forceRefresh: true })).toBeNull(); + }); + it("falls back to null when auth state cannot be read", async () => { + await writeFile( + authPath, + JSON.stringify( + { tokens: { access_token: "a.b.c", refresh_token: "refresh-auth" } }, + null, + 2, + ), + "utf-8", + ); + const realReadFile = fsPromises.readFile.bind(fsPromises); + const readSpy = vi.spyOn(fsPromises, "readFile"); + readSpy.mockImplementation(async (...args) => { + if (String(args[0]) === authPath) { + const error = new Error("auth read failed") as NodeJS.ErrnoException; + error.code = "EIO"; + throw error; + } + return realReadFile(...args); + }); + try { + const state = await loadCodexCliState({ forceRefresh: true }); + expect(state).toBeNull(); + } finally { + readSpy.mockRestore(); + } + }); + it("continues with undefined source mtime when stat retries are exhausted", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { access_token: "a.b.c", refresh_token: "refresh-a" }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + const realStat = fsPromises.stat.bind(fsPromises); + const statSpy = vi.spyOn(fsPromises, "stat"); + statSpy.mockImplementation(async (...args) => { + if (String(args[0]) === accountsPath) { + const error = new Error("locked stat") as NodeJS.ErrnoException; + error.code = "EPERM"; + throw error; + } + return realStat(...args); + }); + try { + const state = await loadCodexCliState({ forceRefresh: true }); + expect(state?.accounts[0]?.accountId).toBe("acc_a"); + expect(state?.sourceUpdatedAtMs).toBeUndefined(); + } finally { + statSpy.mockRestore(); + } + }); + it("returns null after non-retryable account read errors", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { access_token: "a.b.c", refresh_token: "refresh-a" }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + const realReadFile = fsPromises.readFile.bind(fsPromises); + const readSpy = vi.spyOn(fsPromises, "readFile"); + readSpy.mockImplementation(async (...args) => { + if (String(args[0]) === accountsPath) { + const error = new Error("permission denied") as NodeJS.ErrnoException; + error.code = "EACCES"; + throw error; + } + return realReadFile(...args); + }); + try { + expect(await loadCodexCliState({ forceRefresh: true })).toBeNull(); + } finally { + readSpy.mockRestore(); + } + }); + it("returns null for lookup with blank email or missing access token", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { accounts: [{ email: "a@example.com", refresh_token: "refresh-a" }] }, + null, + 2, + ), + "utf-8", + ); + expect(await lookupCodexCliTokensByEmail(" ")).toBeNull(); + expect(await lookupCodexCliTokensByEmail("a@example.com")).toBeNull(); + }); }); diff --git a/test/codex-cli-sync.test.ts b/test/codex-cli-sync.test.ts index 80fd6f0a..11db5fa4 100644 --- a/test/codex-cli-sync.test.ts +++ b/test/codex-cli-sync.test.ts @@ -1,527 +1,882 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { AccountStorageV3 } from "../lib/storage.js"; +import * as codexCliState from "../lib/codex-cli/state.js"; import { clearCodexCliStateCache } from "../lib/codex-cli/state.js"; -import { syncAccountStorageFromCodexCli } from "../lib/codex-cli/sync.js"; +import { + getActiveSelectionForFamily, + syncAccountStorageFromCodexCli, +} from "../lib/codex-cli/sync.js"; import { setCodexCliActiveSelection } from "../lib/codex-cli/writer.js"; +import { MODEL_FAMILIES } from "../lib/prompts/codex.js"; describe("codex-cli sync", () => { - let tempDir: string; - let accountsPath: string; - let authPath: string; - let previousPath: string | undefined; - let previousAuthPath: string | undefined; - let previousSync: string | undefined; - - beforeEach(async () => { - previousPath = process.env.CODEX_CLI_ACCOUNTS_PATH; - previousAuthPath = process.env.CODEX_CLI_AUTH_PATH; - previousSync = process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI; - tempDir = await mkdtemp(join(tmpdir(), "codex-multi-auth-sync-")); - accountsPath = join(tempDir, "accounts.json"); - authPath = join(tempDir, "auth.json"); - process.env.CODEX_CLI_ACCOUNTS_PATH = accountsPath; - process.env.CODEX_CLI_AUTH_PATH = authPath; - process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI = "1"; - clearCodexCliStateCache(); - }); - - afterEach(async () => { - clearCodexCliStateCache(); - if (previousPath === undefined) delete process.env.CODEX_CLI_ACCOUNTS_PATH; - else process.env.CODEX_CLI_ACCOUNTS_PATH = previousPath; - if (previousAuthPath === undefined) delete process.env.CODEX_CLI_AUTH_PATH; - else process.env.CODEX_CLI_AUTH_PATH = previousAuthPath; - if (previousSync === undefined) delete process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI; - else process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI = previousSync; - await rm(tempDir, { recursive: true, force: true }); - }); - - it("merges Codex CLI accounts and sets active index", async () => { - await writeFile( - accountsPath, - JSON.stringify( - { - activeAccountId: "acc_c", - accounts: [ - { - accountId: "acc_b", - email: "b@example.com", - auth: { - tokens: { - access_token: "b.access.token", - refresh_token: "refresh-b", - }, - }, - }, - { - accountId: "acc_c", - email: "c@example.com", - auth: { - tokens: { - access_token: "c.access.token", - refresh_token: "refresh-c", - }, - }, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - - const current: AccountStorageV3 = { - version: 3, - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - refreshToken: "refresh-a", - addedAt: 1, - lastUsed: 1, - }, - { - accountId: "acc_b", - email: "b@example.com", - refreshToken: "refresh-b-old", - addedAt: 2, - lastUsed: 2, - }, - ], - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - }; - - const result = await syncAccountStorageFromCodexCli(current); - expect(result.changed).toBe(true); - expect(result.storage?.accounts.length).toBe(3); - - const mergedB = result.storage?.accounts.find((account) => account.accountId === "acc_b"); - expect(mergedB?.refreshToken).toBe("refresh-b"); - - const active = result.storage?.accounts[result.storage.activeIndex ?? 0]; - expect(active?.accountId).toBe("acc_c"); - }); - - it("creates storage from Codex CLI accounts when local storage is missing", async () => { - await writeFile( - accountsPath, - JSON.stringify( - { - accounts: [ - { - email: "a@example.com", - active: true, - auth: { - tokens: { - access_token: "a.access.token", - refresh_token: "refresh-a", - }, - }, - }, - { - email: "b@example.com", - auth: { - tokens: { - access_token: "b.access.token", - refresh_token: "refresh-b", - }, - }, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - - const result = await syncAccountStorageFromCodexCli(null); - expect(result.changed).toBe(true); - expect(result.storage?.accounts.length).toBe(2); - expect(result.storage?.accounts[0]?.refreshToken).toBe("refresh-a"); - expect(result.storage?.activeIndex).toBe(0); - }); - - it("matches existing account by normalized email", async () => { - await writeFile( - accountsPath, - JSON.stringify( - { - accounts: [ - { - email: "user@example.com", - auth: { - tokens: { - access_token: "new.access.token", - refresh_token: "refresh-new", - }, - }, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - - const current: AccountStorageV3 = { - version: 3, - accounts: [ - { - email: "USER@EXAMPLE.COM", - refreshToken: "refresh-old", - addedAt: 1, - lastUsed: 1, - }, - ], - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - }; - - const result = await syncAccountStorageFromCodexCli(current); - expect(result.changed).toBe(true); - expect(result.storage?.accounts.length).toBe(1); - expect(result.storage?.accounts[0]?.refreshToken).toBe("refresh-new"); - }); - - it("returns unchanged storage when sync is disabled", async () => { - process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI = "0"; - clearCodexCliStateCache(); - - const current: AccountStorageV3 = { - version: 3, - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - refreshToken: "refresh-a", - addedAt: 1, - lastUsed: 1, - }, - ], - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - }; - - const result = await syncAccountStorageFromCodexCli(current); - expect(result.changed).toBe(false); - expect(result.storage).toBe(current); - }); - - it("keeps local active selection when local write is newer than codex snapshot", async () => { - await writeFile( - authPath, - JSON.stringify( - { - auth_mode: "chatgpt", - OPENAI_API_KEY: null, - tokens: { - access_token: "local.access.token", - refresh_token: "local-refresh-token", - account_id: "acc_a", - }, - }, - null, - 2, - ), - "utf-8", - ); - await setCodexCliActiveSelection({ - accountId: "acc_a", - accessToken: "local.access.token", - refreshToken: "local-refresh-token", - }); - - await writeFile( - accountsPath, - JSON.stringify( - { - codexMultiAuthSyncVersion: Date.now() - 120_000, - activeAccountId: "acc_b", - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - auth: { tokens: { access_token: "a.access", refresh_token: "refresh-a" } }, - }, - { - accountId: "acc_b", - email: "b@example.com", - auth: { tokens: { access_token: "b.access", refresh_token: "refresh-b" } }, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - clearCodexCliStateCache(); - - const current: AccountStorageV3 = { - version: 3, - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - refreshToken: "refresh-a", - addedAt: 1, - lastUsed: 1, - }, - { - accountId: "acc_b", - email: "b@example.com", - refreshToken: "refresh-b", - addedAt: 1, - lastUsed: 1, - }, - ], - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - }; - - const result = await syncAccountStorageFromCodexCli(current); - expect(result.storage?.activeIndex).toBe(0); - }); - - it("keeps local active selection when local state is newer by sub-second gap and syncVersion exists", async () => { - await writeFile( - authPath, - JSON.stringify( - { - auth_mode: "chatgpt", - OPENAI_API_KEY: null, - tokens: { - access_token: "local.access.token", - refresh_token: "local-refresh-token", - account_id: "acc_a", - }, - }, - null, - 2, - ), - "utf-8", - ); - await setCodexCliActiveSelection({ - accountId: "acc_a", - accessToken: "local.access.token", - refreshToken: "local-refresh-token", - }); - - const staleSyncVersion = Date.now() - 500; - await writeFile( - accountsPath, - JSON.stringify( - { - codexMultiAuthSyncVersion: staleSyncVersion, - activeAccountId: "acc_b", - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - auth: { tokens: { access_token: "a.access", refresh_token: "refresh-a" } }, - }, - { - accountId: "acc_b", - email: "b@example.com", - auth: { tokens: { access_token: "b.access", refresh_token: "refresh-b" } }, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - clearCodexCliStateCache(); - - const current: AccountStorageV3 = { - version: 3, - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - refreshToken: "refresh-a", - addedAt: 1, - lastUsed: 1, - }, - { - accountId: "acc_b", - email: "b@example.com", - refreshToken: "refresh-b", - addedAt: 1, - lastUsed: 1, - }, - ], - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - }; - - const result = await syncAccountStorageFromCodexCli(current); - expect(result.storage?.activeIndex).toBe(0); - }); - - it("marks changed when local index normalization mutates storage while codex selection is skipped", async () => { - await writeFile( - authPath, - JSON.stringify( - { - auth_mode: "chatgpt", - OPENAI_API_KEY: null, - tokens: { - access_token: "local.access.token", - refresh_token: "local-refresh-token", - account_id: "acc_a", - }, - }, - null, - 2, - ), - "utf-8", - ); - await setCodexCliActiveSelection({ - accountId: "acc_a", - accessToken: "local.access.token", - refreshToken: "local-refresh-token", - }); - - await writeFile( - accountsPath, - JSON.stringify( - { - codexMultiAuthSyncVersion: Date.now() - 120_000, - activeAccountId: "acc_b", - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - auth: { tokens: { access_token: "a.access", refresh_token: "refresh-a" } }, - }, - { - accountId: "acc_b", - email: "b@example.com", - auth: { tokens: { access_token: "b.access", refresh_token: "refresh-b" } }, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - clearCodexCliStateCache(); - - const current: AccountStorageV3 = { - version: 3, - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - refreshToken: "refresh-a", - addedAt: 1, - lastUsed: 1, - }, - { - accountId: "acc_b", - email: "b@example.com", - refreshToken: "refresh-b", - addedAt: 1, - lastUsed: 1, - }, - ], - activeIndex: 99, - activeIndexByFamily: { codex: 99 }, - }; - - const result = await syncAccountStorageFromCodexCli(current); - expect(result.changed).toBe(true); - expect(result.storage?.activeIndex).toBe(1); - expect(result.storage?.activeIndexByFamily?.codex).toBe(1); - }); - - it("serializes concurrent active-selection writes to keep accounts/auth aligned", async () => { - await writeFile( - accountsPath, - JSON.stringify( - { - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - auth: { - tokens: { - access_token: "access-a", - id_token: "id-a", - refresh_token: "refresh-a", - }, - }, - }, - { - accountId: "acc_b", - email: "b@example.com", - auth: { - tokens: { - access_token: "access-b", - id_token: "id-b", - refresh_token: "refresh-b", - }, - }, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - await writeFile( - authPath, - JSON.stringify( - { - auth_mode: "chatgpt", - OPENAI_API_KEY: null, - email: "a@example.com", - tokens: { - access_token: "access-a", - id_token: "id-a", - refresh_token: "refresh-a", - account_id: "acc_a", - }, - }, - null, - 2, - ), - "utf-8", - ); - - const [first, second] = await Promise.all([ - setCodexCliActiveSelection({ accountId: "acc_a" }), - setCodexCliActiveSelection({ accountId: "acc_b" }), - ]); - expect(first).toBe(true); - expect(second).toBe(true); - - const writtenAccounts = JSON.parse(await readFile(accountsPath, "utf-8")) as { - activeAccountId?: string; - activeEmail?: string; - accounts?: Array<{ accountId?: string; active?: boolean }>; - }; - const writtenAuth = JSON.parse(await readFile(authPath, "utf-8")) as { - email?: string; - tokens?: { account_id?: string }; - }; - - expect(writtenAccounts.activeAccountId).toBe("acc_b"); - expect(writtenAccounts.activeEmail).toBe("b@example.com"); - expect(writtenAccounts.accounts?.[0]?.active).toBe(false); - expect(writtenAccounts.accounts?.[1]?.active).toBe(true); - expect(writtenAuth.tokens?.account_id).toBe("acc_b"); - expect(writtenAuth.email).toBe("b@example.com"); - }); + let tempDir: string; + let accountsPath: string; + let authPath: string; + let configPath: string; + let previousPath: string | undefined; + let previousAuthPath: string | undefined; + let previousConfigPath: string | undefined; + let previousSync: string | undefined; + let previousEnforceFileStore: string | undefined; + + beforeEach(async () => { + previousPath = process.env.CODEX_CLI_ACCOUNTS_PATH; + previousAuthPath = process.env.CODEX_CLI_AUTH_PATH; + previousConfigPath = process.env.CODEX_CLI_CONFIG_PATH; + previousSync = process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI; + previousEnforceFileStore = + process.env.CODEX_MULTI_AUTH_ENFORCE_CLI_FILE_AUTH_STORE; + tempDir = await mkdtemp(join(tmpdir(), "codex-multi-auth-sync-")); + accountsPath = join(tempDir, "accounts.json"); + authPath = join(tempDir, "auth.json"); + configPath = join(tempDir, "config.toml"); + process.env.CODEX_CLI_ACCOUNTS_PATH = accountsPath; + process.env.CODEX_CLI_AUTH_PATH = authPath; + process.env.CODEX_CLI_CONFIG_PATH = configPath; + process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI = "1"; + process.env.CODEX_MULTI_AUTH_ENFORCE_CLI_FILE_AUTH_STORE = "1"; + clearCodexCliStateCache(); + }); + + afterEach(async () => { + clearCodexCliStateCache(); + if (previousPath === undefined) delete process.env.CODEX_CLI_ACCOUNTS_PATH; + else process.env.CODEX_CLI_ACCOUNTS_PATH = previousPath; + if (previousAuthPath === undefined) delete process.env.CODEX_CLI_AUTH_PATH; + else process.env.CODEX_CLI_AUTH_PATH = previousAuthPath; + if (previousConfigPath === undefined) delete process.env.CODEX_CLI_CONFIG_PATH; + else process.env.CODEX_CLI_CONFIG_PATH = previousConfigPath; + if (previousSync === undefined) + delete process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI; + else process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI = previousSync; + if (previousEnforceFileStore === undefined) { + delete process.env.CODEX_MULTI_AUTH_ENFORCE_CLI_FILE_AUTH_STORE; + } else { + process.env.CODEX_MULTI_AUTH_ENFORCE_CLI_FILE_AUTH_STORE = + previousEnforceFileStore; + } + await rm(tempDir, { recursive: true, force: true }); + }); + + it("merges Codex CLI accounts and sets active index", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_c", + accounts: [ + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "b.access.token", + refresh_token: "refresh-b", + }, + }, + }, + { + accountId: "acc_c", + email: "c@example.com", + auth: { + tokens: { + access_token: "c.access.token", + refresh_token: "refresh-c", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "acc_b", + email: "b@example.com", + refreshToken: "refresh-b-old", + addedAt: 2, + lastUsed: 2, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + + const result = await syncAccountStorageFromCodexCli(current); + expect(result.changed).toBe(true); + expect(result.storage?.accounts.length).toBe(3); + + const mergedB = result.storage?.accounts.find( + (account) => account.accountId === "acc_b", + ); + expect(mergedB?.refreshToken).toBe("refresh-b"); + + const active = result.storage?.accounts[result.storage.activeIndex ?? 0]; + expect(active?.accountId).toBe("acc_c"); + }); + + it("creates storage from Codex CLI accounts when local storage is missing", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + accounts: [ + { + email: "a@example.com", + active: true, + auth: { + tokens: { + access_token: "a.access.token", + refresh_token: "refresh-a", + }, + }, + }, + { + email: "b@example.com", + auth: { + tokens: { + access_token: "b.access.token", + refresh_token: "refresh-b", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const result = await syncAccountStorageFromCodexCli(null); + expect(result.changed).toBe(true); + expect(result.storage?.accounts.length).toBe(2); + expect(result.storage?.accounts[0]?.refreshToken).toBe("refresh-a"); + expect(result.storage?.activeIndex).toBe(0); + }); + + it("matches existing account by normalized email", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + accounts: [ + { + email: "user@example.com", + auth: { + tokens: { + access_token: "new.access.token", + refresh_token: "refresh-new", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + email: "USER@EXAMPLE.COM", + refreshToken: "refresh-old", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + + const result = await syncAccountStorageFromCodexCli(current); + expect(result.changed).toBe(true); + expect(result.storage?.accounts.length).toBe(1); + expect(result.storage?.accounts[0]?.refreshToken).toBe("refresh-new"); + }); + + it("returns unchanged storage when sync is disabled", async () => { + process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI = "0"; + clearCodexCliStateCache(); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + + const result = await syncAccountStorageFromCodexCli(current); + expect(result.changed).toBe(false); + expect(result.storage).toBe(current); + }); + + it("keeps local active selection when local write is newer than codex snapshot", async () => { + await writeFile( + authPath, + JSON.stringify( + { + auth_mode: "chatgpt", + OPENAI_API_KEY: null, + tokens: { + access_token: "local.access.token", + refresh_token: "local-refresh-token", + account_id: "acc_a", + }, + }, + null, + 2, + ), + "utf-8", + ); + await setCodexCliActiveSelection({ + accountId: "acc_a", + accessToken: "local.access.token", + refreshToken: "local-refresh-token", + }); + + await writeFile( + accountsPath, + JSON.stringify( + { + codexMultiAuthSyncVersion: Date.now() - 120_000, + activeAccountId: "acc_b", + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { + access_token: "a.access", + refresh_token: "refresh-a", + }, + }, + }, + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "b.access", + refresh_token: "refresh-b", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + clearCodexCliStateCache(); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "acc_b", + email: "b@example.com", + refreshToken: "refresh-b", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + + const result = await syncAccountStorageFromCodexCli(current); + expect(result.storage?.activeIndex).toBe(0); + }); + + it("keeps local active selection when local state is newer by sub-second gap and syncVersion exists", async () => { + await writeFile( + authPath, + JSON.stringify( + { + auth_mode: "chatgpt", + OPENAI_API_KEY: null, + tokens: { + access_token: "local.access.token", + refresh_token: "local-refresh-token", + account_id: "acc_a", + }, + }, + null, + 2, + ), + "utf-8", + ); + await setCodexCliActiveSelection({ + accountId: "acc_a", + accessToken: "local.access.token", + refreshToken: "local-refresh-token", + }); + + const staleSyncVersion = Date.now() - 500; + await writeFile( + accountsPath, + JSON.stringify( + { + codexMultiAuthSyncVersion: staleSyncVersion, + activeAccountId: "acc_b", + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { + access_token: "a.access", + refresh_token: "refresh-a", + }, + }, + }, + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "b.access", + refresh_token: "refresh-b", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + clearCodexCliStateCache(); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "acc_b", + email: "b@example.com", + refreshToken: "refresh-b", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + + const result = await syncAccountStorageFromCodexCli(current); + expect(result.storage?.activeIndex).toBe(0); + }); + + it("marks changed when local index normalization mutates storage while codex selection is skipped", async () => { + await writeFile( + authPath, + JSON.stringify( + { + auth_mode: "chatgpt", + OPENAI_API_KEY: null, + tokens: { + access_token: "local.access.token", + refresh_token: "local-refresh-token", + account_id: "acc_a", + }, + }, + null, + 2, + ), + "utf-8", + ); + await setCodexCliActiveSelection({ + accountId: "acc_a", + accessToken: "local.access.token", + refreshToken: "local-refresh-token", + }); + + await writeFile( + accountsPath, + JSON.stringify( + { + codexMultiAuthSyncVersion: Date.now() - 120_000, + activeAccountId: "acc_b", + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { + access_token: "a.access", + refresh_token: "refresh-a", + }, + }, + }, + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "b.access", + refresh_token: "refresh-b", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + clearCodexCliStateCache(); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "acc_b", + email: "b@example.com", + refreshToken: "refresh-b", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 99, + activeIndexByFamily: { codex: 99 }, + }; + + const result = await syncAccountStorageFromCodexCli(current); + expect(result.changed).toBe(true); + expect(result.storage?.activeIndex).toBe(1); + expect(result.storage?.activeIndexByFamily?.codex).toBe(1); + }); + + it("serializes concurrent active-selection writes to keep accounts/auth aligned", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { + access_token: "access-a", + id_token: "id-a", + refresh_token: "refresh-a", + }, + }, + }, + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "access-b", + id_token: "id-b", + refresh_token: "refresh-b", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + await writeFile( + authPath, + JSON.stringify( + { + auth_mode: "chatgpt", + OPENAI_API_KEY: null, + email: "a@example.com", + tokens: { + access_token: "access-a", + id_token: "id-a", + refresh_token: "refresh-a", + account_id: "acc_a", + }, + }, + null, + 2, + ), + "utf-8", + ); + + const [first, second] = await Promise.all([ + setCodexCliActiveSelection({ accountId: "acc_a" }), + setCodexCliActiveSelection({ accountId: "acc_b" }), + ]); + expect(first).toBe(true); + expect(second).toBe(true); + + const writtenAccounts = JSON.parse( + await readFile(accountsPath, "utf-8"), + ) as { + activeAccountId?: string; + activeEmail?: string; + accounts?: Array<{ accountId?: string; active?: boolean }>; + }; + const writtenAuth = JSON.parse(await readFile(authPath, "utf-8")) as { + email?: string; + tokens?: { account_id?: string }; + }; + + expect(writtenAccounts.activeAccountId).toBe("acc_b"); + expect(writtenAccounts.activeEmail).toBe("b@example.com"); + expect(writtenAccounts.accounts?.[0]?.active).toBe(false); + expect(writtenAccounts.accounts?.[1]?.active).toBe(true); + expect(writtenAuth.tokens?.account_id).toBe("acc_b"); + expect(writtenAuth.email).toBe("b@example.com"); + }); + it("ignores Codex snapshots that do not include refresh tokens", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + access_token: "access-only", + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const result = await syncAccountStorageFromCodexCli(null); + expect(result.changed).toBe(false); + expect(result.storage?.accounts).toHaveLength(0); + expect(result.storage?.activeIndex).toBe(0); + }); + + it("matches existing account by refresh token when accountId is absent", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + accounts: [ + { + email: "updated@example.com", + auth: { + tokens: { + access_token: "new-access", + refresh_token: "refresh-a", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + accountIdSource: "token", + email: "a@example.com", + refreshToken: "refresh-a", + accessToken: "old-access", + enabled: true, + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + + const result = await syncAccountStorageFromCodexCli(current); + expect(result.changed).toBe(true); + expect(result.storage?.accounts[0]?.accessToken).toBe("new-access"); + expect(result.storage?.accounts[0]?.email).toBe("updated@example.com"); + }); + + it("returns unchanged when Codex state and local selection are already aligned", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_a", + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { + access_token: "access-a", + refresh_token: "refresh-a", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const familyIndexes = Object.fromEntries( + MODEL_FAMILIES.map((family) => [family, 0]), + ); + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + accountIdSource: "token", + email: "a@example.com", + refreshToken: "refresh-a", + accessToken: "access-a", + enabled: true, + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: familyIndexes, + }; + + const result = await syncAccountStorageFromCodexCli(current); + expect(result.changed).toBe(false); + expect(result.storage).toEqual(current); + }); + + it("returns current storage when state loading throws", async () => { + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + + const loadSpy = vi + .spyOn(codexCliState, "loadCodexCliState") + .mockRejectedValue(new Error("forced load failure")); + + try { + const result = await syncAccountStorageFromCodexCli(current); + expect(result.changed).toBe(false); + expect(result.storage).toBe(current); + } finally { + loadSpy.mockRestore(); + } + }); + + it("applies active selection using normalized email when accountId is absent", async () => { + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "acc_b", + email: "b@example.com", + refreshToken: "refresh-b", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + + const loadSpy = vi + .spyOn(codexCliState, "loadCodexCliState") + .mockResolvedValue({ + path: "mock", + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + accessToken: "a.access.token", + refreshToken: "refresh-a", + }, + { + accountId: "acc_b", + email: "b@example.com", + accessToken: "b.access.token", + refreshToken: "refresh-b", + }, + ], + activeEmail: " B@EXAMPLE.COM ", + }); + + try { + const result = await syncAccountStorageFromCodexCli(current); + expect(result.storage?.activeIndex).toBe(1); + } finally { + loadSpy.mockRestore(); + } + }); + + it("initializes family indexes when local storage omits activeIndexByFamily", async () => { + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "acc_b", + email: "b@example.com", + refreshToken: "refresh-b", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 1, + }; + + const loadSpy = vi + .spyOn(codexCliState, "loadCodexCliState") + .mockResolvedValue({ + path: "mock", + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + accessToken: "a.access.token", + refreshToken: "refresh-a", + }, + ], + activeAccountId: "acc_a", + syncVersion: undefined, + sourceUpdatedAtMs: undefined, + }); + + try { + const result = await syncAccountStorageFromCodexCli(current); + expect(result.storage?.activeIndex).toBe(0); + for (const family of MODEL_FAMILIES) { + expect(result.storage?.activeIndexByFamily?.[family]).toBe(0); + } + } finally { + loadSpy.mockRestore(); + } + }); + + it("clamps and defaults active selection indexes by model family", () => { + const family = MODEL_FAMILIES[0]; + expect( + getActiveSelectionForFamily( + { + version: 3, + accounts: [], + activeIndex: 99, + activeIndexByFamily: {}, + }, + family, + ), + ).toBe(0); + + expect( + getActiveSelectionForFamily( + { + version: 3, + accounts: [ + { refreshToken: "a", addedAt: 1, lastUsed: 1 }, + { refreshToken: "b", addedAt: 1, lastUsed: 1 }, + ], + activeIndex: 1, + activeIndexByFamily: { [family]: Number.NaN }, + }, + family, + ), + ).toBe(1); + + expect( + getActiveSelectionForFamily( + { + version: 3, + accounts: [ + { refreshToken: "a", addedAt: 1, lastUsed: 1 }, + { refreshToken: "b", addedAt: 1, lastUsed: 1 }, + ], + activeIndex: 1, + activeIndexByFamily: { [family]: -3 }, + }, + family, + ), + ).toBe(0); + }); }); diff --git a/test/codex-cli-writer.test.ts b/test/codex-cli-writer.test.ts new file mode 100644 index 00000000..1d090364 --- /dev/null +++ b/test/codex-cli-writer.test.ts @@ -0,0 +1,487 @@ +import { promises as fsPromises } from "node:fs"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + getCodexCliMetricsSnapshot, + resetCodexCliMetricsForTests, +} from "../lib/codex-cli/observability.js"; +import { clearCodexCliStateCache } from "../lib/codex-cli/state.js"; +import { setCodexCliActiveSelection } from "../lib/codex-cli/writer.js"; + +describe("codex-cli writer", () => { + let tempDir: string; + let accountsPath: string; + let authPath: string; + let configPath: string; + let previousPath: string | undefined; + let previousAuthPath: string | undefined; + let previousConfigPath: string | undefined; + let previousSync: string | undefined; + let previousEnforceFileStore: string | undefined; + + beforeEach(async () => { + previousPath = process.env.CODEX_CLI_ACCOUNTS_PATH; + previousAuthPath = process.env.CODEX_CLI_AUTH_PATH; + previousConfigPath = process.env.CODEX_CLI_CONFIG_PATH; + previousSync = process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI; + previousEnforceFileStore = + process.env.CODEX_MULTI_AUTH_ENFORCE_CLI_FILE_AUTH_STORE; + tempDir = await mkdtemp(join(tmpdir(), "codex-multi-auth-writer-")); + accountsPath = join(tempDir, "accounts.json"); + authPath = join(tempDir, "auth.json"); + configPath = join(tempDir, "config.toml"); + process.env.CODEX_CLI_ACCOUNTS_PATH = accountsPath; + process.env.CODEX_CLI_AUTH_PATH = authPath; + process.env.CODEX_CLI_CONFIG_PATH = configPath; + process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI = "1"; + process.env.CODEX_MULTI_AUTH_ENFORCE_CLI_FILE_AUTH_STORE = "1"; + clearCodexCliStateCache(); + resetCodexCliMetricsForTests(); + }); + + afterEach(async () => { + clearCodexCliStateCache(); + if (previousPath === undefined) delete process.env.CODEX_CLI_ACCOUNTS_PATH; + else process.env.CODEX_CLI_ACCOUNTS_PATH = previousPath; + if (previousAuthPath === undefined) delete process.env.CODEX_CLI_AUTH_PATH; + else process.env.CODEX_CLI_AUTH_PATH = previousAuthPath; + if (previousConfigPath === undefined) delete process.env.CODEX_CLI_CONFIG_PATH; + else process.env.CODEX_CLI_CONFIG_PATH = previousConfigPath; + if (previousSync === undefined) + delete process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI; + else process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI = previousSync; + if (previousEnforceFileStore === undefined) { + delete process.env.CODEX_MULTI_AUTH_ENFORCE_CLI_FILE_AUTH_STORE; + } else { + process.env.CODEX_MULTI_AUTH_ENFORCE_CLI_FILE_AUTH_STORE = + previousEnforceFileStore; + } + resetCodexCliMetricsForTests(); + await rm(tempDir, { recursive: true, force: true }); + }); + + it("returns false when neither accounts.json nor auth.json exists", async () => { + const updated = await setCodexCliActiveSelection({ accountId: "missing" }); + expect(updated).toBe(false); + expect(getCodexCliMetricsSnapshot().writeFailures).toBeGreaterThanOrEqual( + 1, + ); + }); + + it("creates auth.json when missing and selection includes tokens", async () => { + const updated = await setCodexCliActiveSelection({ + accountId: "acc_new", + email: "new@example.com", + accessToken: "new-access", + refreshToken: "new-refresh", + }); + expect(updated).toBe(true); + + const writtenAuth = JSON.parse(await readFile(authPath, "utf-8")) as { + email?: string; + tokens?: { + access_token?: string; + id_token?: string; + refresh_token?: string; + account_id?: string; + }; + }; + expect(writtenAuth.email).toBe("new@example.com"); + expect(writtenAuth.tokens?.access_token).toBe("new-access"); + expect(writtenAuth.tokens?.id_token).toBe("new-access"); + expect(writtenAuth.tokens?.refresh_token).toBe("new-refresh"); + expect(writtenAuth.tokens?.account_id).toBe("acc_new"); + }); + + it("forces file-backed Codex auth store in config.toml", async () => { + const updated = await setCodexCliActiveSelection({ + accountId: "acc_new", + email: "new@example.com", + accessToken: "new-access", + refreshToken: "new-refresh", + }); + expect(updated).toBe(true); + + const writtenConfig = await readFile(configPath, "utf-8"); + expect(writtenConfig).toContain('cli_auth_credentials_store = "file"'); + }); + + it("matches by email, preserves non-record entries, and writes string expires_at as ISO time", async () => { + const expiresAtMs = 1_710_000_000_000; + await writeFile( + accountsPath, + JSON.stringify( + { + accounts: [ + "noise-entry", + { + accountId: 123, + email: "target@example.com", + expires_at: String(expiresAtMs), + auth: { + tokens: { + access_token: "target-access", + refresh_token: "target-refresh", + }, + }, + }, + { + accountId: "acc_other", + email: "other@example.com", + auth: { + tokens: { + access_token: "other-access", + refresh_token: "other-refresh", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + await writeFile( + authPath, + JSON.stringify( + { + auth_mode: "chatgpt", + tokens: { + access_token: "old-access", + id_token: "old-id", + refresh_token: "old-refresh", + }, + }, + null, + 2, + ), + "utf-8", + ); + + const updated = await setCodexCliActiveSelection({ + email: "TARGET@EXAMPLE.COM", + }); + expect(updated).toBe(true); + + const writtenAccounts = JSON.parse( + await readFile(accountsPath, "utf-8"), + ) as { + activeEmail?: string; + accounts?: unknown[]; + }; + expect(writtenAccounts.activeEmail).toBe("target@example.com"); + expect(writtenAccounts.accounts?.[0]).toBe("noise-entry"); + + const writtenAuth = JSON.parse(await readFile(authPath, "utf-8")) as { + email?: string; + last_refresh?: string; + tokens?: { access_token?: string; refresh_token?: string }; + }; + expect(writtenAuth.email).toBe("target@example.com"); + expect(writtenAuth.tokens?.access_token).toBe("target-access"); + expect(writtenAuth.tokens?.refresh_token).toBe("target-refresh"); + expect(writtenAuth.last_refresh).toBe(new Date(expiresAtMs).toISOString()); + }); + + it("returns false for malformed auth payload objects", async () => { + await writeFile(authPath, "[]", "utf-8"); + + const updated = await setCodexCliActiveSelection({ + accountId: "acc_a", + accessToken: "access-a", + refreshToken: "refresh-a", + }); + expect(updated).toBe(false); + }); + + it("returns false when auth payload has no usable access/refresh tokens", async () => { + await writeFile(authPath, JSON.stringify({ tokens: {} }, null, 2), "utf-8"); + + const updated = await setCodexCliActiveSelection({ accountId: "acc_a" }); + expect(updated).toBe(false); + }); + + it("returns false when accounts payload cannot be parsed", async () => { + await writeFile(accountsPath, "{ not-json", "utf-8"); + + const updated = await setCodexCliActiveSelection({ accountId: "acc_a" }); + expect(updated).toBe(false); + }); + + it("retries EPERM rename and eventually succeeds", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { + access_token: "access-a", + refresh_token: "refresh-a", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const realRename = fsPromises.rename.bind(fsPromises); + let attempts = 0; + const renameSpy = vi.spyOn(fsPromises, "rename"); + renameSpy.mockImplementation(async (...args) => { + attempts += 1; + if (attempts === 1) { + const error = new Error("busy") as NodeJS.ErrnoException; + error.code = "EPERM"; + throw error; + } + return realRename(...args); + }); + + try { + const updated = await setCodexCliActiveSelection({ accountId: "acc_a" }); + expect(updated).toBe(true); + expect(attempts).toBeGreaterThanOrEqual(2); + } finally { + renameSpy.mockRestore(); + } + }); + + it("returns false when rename remains busy across all retries", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { + access_token: "access-a", + refresh_token: "refresh-a", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const renameSpy = vi.spyOn(fsPromises, "rename"); + renameSpy.mockImplementation(async () => { + const error = new Error("still busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + }); + + try { + const updated = await setCodexCliActiveSelection({ accountId: "acc_a" }); + expect(updated).toBe(false); + } finally { + renameSpy.mockRestore(); + } + }); + + it("uses auth token fallback/default auth mode when selected account lacks tokens", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + accounts: [ + { + accountId: "acc_a", + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + await writeFile( + authPath, + JSON.stringify( + { + auth_mode: 123, + tokens: { + access_token: "fallback-access", + refresh_token: "fallback-refresh", + id_token: "fallback-id", + }, + }, + null, + 2, + ), + "utf-8", + ); + + const updated = await setCodexCliActiveSelection({ + accountId: "acc_a", + email: "fallback@example.com", + }); + expect(updated).toBe(true); + + const writtenAccounts = JSON.parse( + await readFile(accountsPath, "utf-8"), + ) as { + activeEmail?: string; + }; + expect(writtenAccounts.activeEmail).toBe("fallback@example.com"); + + const writtenAuth = JSON.parse(await readFile(authPath, "utf-8")) as { + auth_mode?: unknown; + email?: string; + tokens?: { + access_token?: string; + refresh_token?: string; + id_token?: string; + }; + }; + expect(writtenAuth.auth_mode).toBe("chatgpt"); + expect(writtenAuth.email).toBe("fallback@example.com"); + expect(writtenAuth.tokens?.access_token).toBe("fallback-access"); + expect(writtenAuth.tokens?.refresh_token).toBe("fallback-refresh"); + expect(writtenAuth.tokens?.id_token).toBe("fallback-id"); + }); + + it("writes auth from explicit selection when persisted tokens payload is not an object", async () => { + await writeFile( + authPath, + JSON.stringify( + { + auth_mode: "chatgpt", + tokens: [], + }, + null, + 2, + ), + "utf-8", + ); + + const updated = await setCodexCliActiveSelection({ + accountId: "acc_direct", + email: "direct@example.com", + accessToken: "direct-access", + refreshToken: "direct-refresh", + }); + expect(updated).toBe(true); + + const writtenAuth = JSON.parse(await readFile(authPath, "utf-8")) as { + email?: string; + tokens?: { + account_id?: string; + access_token?: string; + id_token?: string; + refresh_token?: string; + }; + }; + expect(writtenAuth.email).toBe("direct@example.com"); + expect(writtenAuth.tokens?.account_id).toBe("acc_direct"); + expect(writtenAuth.tokens?.access_token).toBe("direct-access"); + expect(writtenAuth.tokens?.id_token).toBe("direct-access"); + expect(writtenAuth.tokens?.refresh_token).toBe("direct-refresh"); + }); + + it("falls back id_token to selected access token when idToken is missing", async () => { + await writeFile( + authPath, + JSON.stringify( + { + auth_mode: "chatgpt", + email: "old@example.com", + tokens: { + access_token: "old-access", + refresh_token: "old-refresh", + id_token: "old-id-token", + }, + }, + null, + 2, + ), + "utf-8", + ); + + const updated = await setCodexCliActiveSelection({ + accountId: "acc_new", + email: "new@example.com", + accessToken: "new-access", + refreshToken: "new-refresh", + }); + expect(updated).toBe(true); + + const writtenAuth = JSON.parse(await readFile(authPath, "utf-8")) as { + email?: string; + tokens?: { + account_id?: string; + access_token?: string; + refresh_token?: string; + id_token?: string; + }; + }; + expect(writtenAuth.email).toBe("new@example.com"); + expect(writtenAuth.tokens?.account_id).toBe("acc_new"); + expect(writtenAuth.tokens?.access_token).toBe("new-access"); + expect(writtenAuth.tokens?.refresh_token).toBe("new-refresh"); + expect(writtenAuth.tokens?.id_token).toBe("new-access"); + }); + + it("surfaces auth-path errors when accounts file is absent", async () => { + await writeFile(authPath, "{not-json", "utf-8"); + + const updated = await setCodexCliActiveSelection({ + accountId: "acc_only_auth", + accessToken: "a", + refreshToken: "r", + }); + expect(updated).toBe(false); + }); + + it("rejects partial token payloads to avoid mixing stale auth tokens", async () => { + await writeFile( + authPath, + JSON.stringify( + { + auth_mode: "chatgpt", + tokens: { + access_token: "old-access", + refresh_token: "old-refresh", + account_id: "old-account", + }, + }, + null, + 2, + ), + "utf-8", + ); + + const updated = await setCodexCliActiveSelection({ + accountId: "acc_partial", + email: "partial@example.com", + accessToken: "new-access-only", + }); + expect(updated).toBe(false); + + const writtenAuth = JSON.parse(await readFile(authPath, "utf-8")) as { + tokens?: { access_token?: string; refresh_token?: string; account_id?: string }; + email?: string; + }; + expect(writtenAuth.tokens?.access_token).toBe("old-access"); + expect(writtenAuth.tokens?.refresh_token).toBe("old-refresh"); + expect(writtenAuth.tokens?.account_id).toBe("old-account"); + expect(writtenAuth.email).toBeUndefined(); + }); +}); diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index bffc9ea7..27261cd2 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -33,7 +33,7 @@ vi.mock("../lib/auth/auth.js", () => ({ createAuthorizationFlow: vi.fn(), exchangeAuthorizationCode: vi.fn(), parseAuthorizationInput: vi.fn(), - REDIRECT_URI: "http://127.0.0.1:1455/auth/callback", + REDIRECT_URI: "http://localhost:1455/auth/callback", })); vi.mock("../lib/auth/browser.js", () => ({ @@ -551,7 +551,7 @@ describe("codex manager cli commands", () => { expect(payload.recommendedSwitchCommand).toBe("codex auth switch 2"); }); - it("keeps local switch when Codex auth sync fails", async () => { + it("keeps local switch active when Codex auth sync fails", async () => { const now = Date.now(); loadAccountsMock.mockResolvedValueOnce({ version: 3, @@ -585,6 +585,167 @@ describe("codex manager cli commands", () => { ); }); + it("refreshes token pair during switch when cached access token is missing", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "a@example.com", + accountId: "acc_a", + refreshToken: "refresh-a", + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + queuedRefreshMock.mockResolvedValueOnce({ + type: "success", + access: "access-a-next", + refresh: "refresh-a-next", + expires: now + 3_600_000, + idToken: "id-a-next", + }); + setCodexCliActiveSelectionMock.mockResolvedValueOnce(true); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "switch", "1"]); + + expect(exitCode).toBe(0); + expect(queuedRefreshMock).toHaveBeenCalledTimes(1); + expect(saveAccountsMock).toHaveBeenCalledTimes(1); + expect(setCodexCliActiveSelectionMock).toHaveBeenCalledWith( + expect.objectContaining({ + accessToken: "access-a-next", + refreshToken: "refresh-a-next", + expiresAt: now + 3_600_000, + idToken: "id-a-next", + }), + ); + }); + + it("warns on switch validation refresh failure and keeps local active index", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "a@example.com", + accountId: "acc_a", + refreshToken: "refresh-a", + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + queuedRefreshMock.mockResolvedValueOnce({ + type: "failed", + reason: "http_error", + statusCode: 401, + message: "refresh revoked", + }); + setCodexCliActiveSelectionMock.mockResolvedValueOnce(false); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "switch", "1"]); + + expect(exitCode).toBe(0); + expect(queuedRefreshMock).toHaveBeenCalledTimes(1); + expect(saveAccountsMock).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("Switch validation refresh failed"), + ); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("Codex auth sync did not complete"), + ); + }); + + it("autoSyncActiveAccountToCodex syncs active account without refresh when access is valid", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "a@example.com", + accountId: "acc_a", + refreshToken: "refresh-a", + accessToken: "access-a", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + setCodexCliActiveSelectionMock.mockResolvedValueOnce(true); + + const { autoSyncActiveAccountToCodex } = await import("../lib/codex-manager.js"); + const synced = await autoSyncActiveAccountToCodex(); + + expect(synced).toBe(true); + expect(queuedRefreshMock).not.toHaveBeenCalled(); + expect(saveAccountsMock).not.toHaveBeenCalled(); + expect(setCodexCliActiveSelectionMock).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "acc_a", + email: "a@example.com", + accessToken: "access-a", + refreshToken: "refresh-a", + }), + ); + }); + + it("autoSyncActiveAccountToCodex refreshes missing access token then syncs", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "a@example.com", + accountId: "acc_a", + refreshToken: "refresh-a", + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + queuedRefreshMock.mockResolvedValueOnce({ + type: "success", + access: "access-a-next", + refresh: "refresh-a-next", + expires: now + 3_600_000, + idToken: "id-a-next", + }); + setCodexCliActiveSelectionMock.mockResolvedValueOnce(true); + + const { autoSyncActiveAccountToCodex } = await import("../lib/codex-manager.js"); + const synced = await autoSyncActiveAccountToCodex(); + + expect(synced).toBe(true); + expect(queuedRefreshMock).toHaveBeenCalledTimes(1); + expect(saveAccountsMock).toHaveBeenCalledTimes(1); + expect(setCodexCliActiveSelectionMock).toHaveBeenCalledWith( + expect.objectContaining({ + accessToken: "access-a-next", + refreshToken: "refresh-a-next", + expiresAt: now + 3_600_000, + idToken: "id-a-next", + }), + ); + }); + it("keeps auth login menu open after switch until user cancels", async () => { const now = Date.now(); const storage = { diff --git a/test/config-save.test.ts b/test/config-save.test.ts new file mode 100644 index 00000000..2064faeb --- /dev/null +++ b/test/config-save.test.ts @@ -0,0 +1,232 @@ +import { promises as fs } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const RETRYABLE_REMOVE_CODES = new Set(["EBUSY", "EPERM", "ENOTEMPTY"]); + +async function removeWithRetry( + targetPath: string, + options: { recursive?: boolean; force?: boolean }, +): Promise { + for (let attempt = 0; attempt < 6; attempt += 1) { + try { + await fs.rm(targetPath, options); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") return; + if (!code || !RETRYABLE_REMOVE_CODES.has(code) || attempt === 5) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 25 * 2 ** attempt)); + } + } +} + +describe("plugin config save paths", () => { + let tempDir = ""; + const envKeys = [ + "CODEX_MULTI_AUTH_DIR", + "CODEX_MULTI_AUTH_CONFIG_PATH", + "CODEX_HOME", + "CODEX_AUTH_PARALLEL_PROBING", + "CODEX_AUTH_PARALLEL_PROBING_MAX_CONCURRENCY", + ] as const; + const previousEnv: Partial< + Record<(typeof envKeys)[number], string | undefined> + > = {}; + + beforeEach(async () => { + for (const key of envKeys) { + previousEnv[key] = process.env[key]; + } + tempDir = await fs.mkdtemp(join(tmpdir(), "codex-config-save-")); + process.env.CODEX_MULTI_AUTH_DIR = tempDir; + vi.resetModules(); + }); + + afterEach(async () => { + for (const key of envKeys) { + const previous = previousEnv[key]; + if (previous === undefined) { + delete process.env[key]; + } else { + process.env[key] = previous; + } + } + vi.restoreAllMocks(); + vi.resetModules(); + if (tempDir) { + await removeWithRetry(tempDir, { recursive: true, force: true }); + } + }); + + it("merges and sanitizes env-path saves", async () => { + const configPath = join(tempDir, "plugin-config.json"); + process.env.CODEX_MULTI_AUTH_CONFIG_PATH = configPath; + await fs.writeFile( + configPath, + JSON.stringify({ codexMode: true, preserved: 1 }), + "utf8", + ); + + const { savePluginConfig } = await import("../lib/config.js"); + await savePluginConfig({ + codexTuiV2: false, + retryAllAccountsMaxRetries: Number.POSITIVE_INFINITY, + unsupportedCodexFallbackChain: { "gpt-5": ["gpt-4o"] }, + parallelProbing: undefined, + }); + + const parsed = JSON.parse(await fs.readFile(configPath, "utf8")) as Record< + string, + unknown + >; + expect(parsed.codexMode).toBe(true); + expect(parsed.preserved).toBe(1); + expect(parsed.codexTuiV2).toBe(false); + expect(parsed.retryAllAccountsMaxRetries).toBeUndefined(); + expect(parsed.parallelProbing).toBeUndefined(); + expect(parsed.unsupportedCodexFallbackChain).toEqual({ + "gpt-5": ["gpt-4o"], + }); + }); + + it("recovers from malformed env-path JSON before saving", async () => { + const configPath = join(tempDir, "plugin-config.json"); + process.env.CODEX_MULTI_AUTH_CONFIG_PATH = configPath; + await fs.writeFile(configPath, "{ malformed", "utf8"); + + const { savePluginConfig } = await import("../lib/config.js"); + await savePluginConfig({ codexMode: false, fastSession: true }); + + const parsed = JSON.parse(await fs.readFile(configPath, "utf8")) as Record< + string, + unknown + >; + expect(parsed.codexMode).toBe(false); + expect(parsed.fastSession).toBe(true); + }); + + it("cleans temp files when env-path rename target is invalid", async () => { + const invalidTarget = join(tempDir, "config-target-dir"); + process.env.CODEX_MULTI_AUTH_CONFIG_PATH = invalidTarget; + await fs.mkdir(invalidTarget, { recursive: true }); + + const { savePluginConfig } = await import("../lib/config.js"); + await expect(savePluginConfig({ codexMode: false })).rejects.toBeTruthy(); + + const entries = await fs.readdir(tempDir); + const leakedTemps = entries.filter( + (name) => name.startsWith("config-target-dir.") && name.endsWith(".tmp"), + ); + expect(leakedTemps).toHaveLength(0); + }); + + it("writes through unified settings when env path is unset", async () => { + delete process.env.CODEX_MULTI_AUTH_CONFIG_PATH; + + const { savePluginConfig, loadPluginConfig } = + await import("../lib/config.js"); + await savePluginConfig({ + codexMode: false, + parallelProbing: true, + parallelProbingMaxConcurrency: 7, + }); + + const loaded = loadPluginConfig(); + expect(loaded.codexMode).toBe(false); + expect(loaded.parallelProbing).toBe(true); + expect(loaded.parallelProbingMaxConcurrency).toBe(7); + }); + + it("resolves parallel probing settings and clamps concurrency", async () => { + const { getParallelProbing, getParallelProbingMaxConcurrency } = + await import("../lib/config.js"); + + process.env.CODEX_AUTH_PARALLEL_PROBING = "1"; + expect(getParallelProbing({ parallelProbing: false })).toBe(true); + process.env.CODEX_AUTH_PARALLEL_PROBING = "0"; + expect(getParallelProbing({ parallelProbing: true })).toBe(false); + + process.env.CODEX_AUTH_PARALLEL_PROBING_MAX_CONCURRENCY = "not-a-number"; + expect( + getParallelProbingMaxConcurrency({ parallelProbingMaxConcurrency: 4 }), + ).toBe(4); + + process.env.CODEX_AUTH_PARALLEL_PROBING_MAX_CONCURRENCY = "0"; + expect( + getParallelProbingMaxConcurrency({ parallelProbingMaxConcurrency: 4 }), + ).toBe(1); + }); + + it("normalizes fallback chain and drops invalid entries", async () => { + const { getUnsupportedCodexFallbackChain } = + await import("../lib/config.js"); + + const chain = getUnsupportedCodexFallbackChain({ + unsupportedCodexFallbackChain: { + " OpenAI/GPT-5.3-CODEX ": ["gpt-5.2-codex", 99 as unknown as string], + "gpt-5.3-codex-mini": "gpt-5" as unknown as string[], + }, + }); + + expect(chain).toEqual({ + "gpt-5.3-codex": ["gpt-5.2-codex"], + }); + }); + + it("loads global legacy config and auth paths when discovered", async () => { + delete process.env.CODEX_HOME; + + const runCase = async (legacyFilename: string) => { + vi.resetModules(); + const existsSyncMock = vi.fn((candidate: unknown) => { + if (typeof candidate !== "string") return false; + const normalized = candidate.replace(/\\/g, "/"); + return normalized.endsWith(`/${legacyFilename}`); + }); + const readFileSyncMock = vi.fn(() => + JSON.stringify({ codexMode: false }), + ); + const logWarnMock = vi.fn(); + + vi.doMock("node:fs", async () => { + const actual = + await vi.importActual("node:fs"); + return { + ...actual, + existsSync: existsSyncMock, + readFileSync: readFileSyncMock, + }; + }); + vi.doMock("../lib/logger.js", async () => { + const actual = + await vi.importActual( + "../lib/logger.js", + ); + return { + ...actual, + logWarn: logWarnMock, + }; + }); + + try { + const configModule = await import("../lib/config.js"); + const loaded = configModule.loadPluginConfig(); + expect(loaded.codexMode).toBe(false); + expect(readFileSyncMock).toHaveBeenCalled(); + expect(logWarnMock).toHaveBeenCalledWith( + expect.stringContaining(legacyFilename), + ); + } finally { + vi.doUnmock("node:fs"); + vi.doUnmock("../lib/logger.js"); + } + }; + + await runCase("codex-multi-auth-config.json"); + await runCase("openai-codex-auth-config.json"); + }); +}); diff --git a/test/copy-oauth-success.test.ts b/test/copy-oauth-success.test.ts index c214f0a0..6db3aaf3 100644 --- a/test/copy-oauth-success.test.ts +++ b/test/copy-oauth-success.test.ts @@ -1,32 +1,105 @@ -import { describe, it, expect } from "vitest"; -import { mkdtemp, writeFile, readFile, rm } from "node:fs/promises"; -import { join } from "node:path"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it, vi } from "vitest"; + +const oauthSuccessPath = fileURLToPath( + new URL("../lib/oauth-success.html", import.meta.url), +); +const normalizeLineEndings = (value: string) => value.replace(/\r\n/g, "\n"); describe("copy-oauth-success script", () => { - it("exports copyOAuthSuccessHtml() for reuse/testing", async () => { - const mod = await import("../scripts/copy-oauth-success.js"); - expect(typeof mod.copyOAuthSuccessHtml).toBe("function"); - }); - - it("copies oauth-success.html to the requested destination", async () => { - const mod = await import("../scripts/copy-oauth-success.js"); - - const root = await mkdtemp(join(tmpdir(), "codex-oauth-success-")); - const src = join(root, "oauth-success.html"); - const dest = join(root, "dist", "lib", "oauth-success.html"); - - try { - const html = "ok"; - await writeFile(src, html, "utf-8"); - - await mod.copyOAuthSuccessHtml({ src, dest }); - - const copied = await readFile(dest, "utf-8"); - expect(copied).toBe(html); - } finally { - await rm(root, { recursive: true, force: true }); - } - }); -}); + it("exports copyOAuthSuccessHtml() for reuse/testing", async () => { + vi.resetModules(); + const mod = await import("../scripts/copy-oauth-success.js"); + expect(typeof mod.copyOAuthSuccessHtml).toBe("function"); + }); + + it("copies oauth-success.html to the requested destination and matches snapshot", async () => { + vi.resetModules(); + const mod = await import("../scripts/copy-oauth-success.js"); + + const root = await mkdtemp(join(tmpdir(), "codex-oauth-success-")); + const dest = join(root, "dist", "lib", "oauth-success.html"); + + try { + await mod.copyOAuthSuccessHtml({ src: oauthSuccessPath, dest }); + const copied = await readFile(dest, "utf-8"); + const source = await readFile(oauthSuccessPath, "utf-8"); + expect(copied).toBe(source); + expect(normalizeLineEndings(copied)).toMatchSnapshot(); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("retries when copyFile hits transient lock errors", async () => { + const retryableCodes = ["EBUSY", "EPERM", "EACCES"]; + for (const code of retryableCodes) { + vi.resetModules(); + const actualFs = + await vi.importActual( + "node:fs/promises", + ); + let attempt = 0; + const transientError = Object.assign(new Error(`transient ${code}`), { code }); + const mockCopyFile = vi.fn( + (...args: Parameters) => { + attempt += 1; + if (attempt === 1) { + return Promise.reject(transientError); + } + return actualFs.copyFile(...args); + }, + ); + vi.doMock("node:fs/promises", () => ({ + ...actualFs, + copyFile: mockCopyFile, + })); + + const mod = await import("../scripts/copy-oauth-success.js"); + const root = await mkdtemp(join(tmpdir(), `codex-oauth-success-${code.toLowerCase()}-`)); + const dest = join(root, "dist", "lib", "oauth-success.html"); + + try { + await mod.copyOAuthSuccessHtml({ src: oauthSuccessPath, dest }); + expect(mockCopyFile).toHaveBeenCalledTimes(2); + } finally { + await rm(root, { recursive: true, force: true }); + vi.doUnmock("node:fs/promises"); + vi.resetModules(); + } + } + }); + + it("throws immediately for non-retryable copy errors", async () => { + vi.resetModules(); + const actualFs = + await vi.importActual( + "node:fs/promises", + ); + const error = Object.assign(new Error("missing file"), { code: "ENOENT" }); + const mockCopyFile = vi.fn().mockRejectedValue(error); + vi.doMock("node:fs/promises", () => ({ + ...actualFs, + copyFile: mockCopyFile, + })); + + const mod = await import("../scripts/copy-oauth-success.js"); + const root = await mkdtemp(join(tmpdir(), "codex-oauth-success-enoent-")); + const dest = join(root, "dist", "lib", "oauth-success.html"); + + try { + await expect(mod.copyOAuthSuccessHtml({ src: oauthSuccessPath, dest })).rejects.toMatchObject({ + code: "ENOENT", + }); + expect(mockCopyFile).toHaveBeenCalledTimes(1); + } finally { + await rm(root, { recursive: true, force: true }); + vi.doUnmock("node:fs/promises"); + vi.resetModules(); + } + }); +}); diff --git a/test/dashboard-settings.test.ts b/test/dashboard-settings.test.ts index ce064224..ceb521f1 100644 --- a/test/dashboard-settings.test.ts +++ b/test/dashboard-settings.test.ts @@ -4,208 +4,456 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; describe("dashboard settings", () => { - let tempDir: string; - let originalDir: string | undefined; - - beforeEach(async () => { - originalDir = process.env.CODEX_MULTI_AUTH_DIR; - tempDir = await fs.mkdtemp(join(tmpdir(), "codex-multi-auth-dashboard-")); - process.env.CODEX_MULTI_AUTH_DIR = tempDir; - vi.resetModules(); - }); - - afterEach(async () => { - if (originalDir === undefined) { - delete process.env.CODEX_MULTI_AUTH_DIR; - } else { - process.env.CODEX_MULTI_AUTH_DIR = originalDir; - } - await fs.rm(tempDir, { recursive: true, force: true }); - }); - - it("loads defaults when settings file does not exist", async () => { - const { loadDashboardDisplaySettings, DEFAULT_DASHBOARD_DISPLAY_SETTINGS } = await import( - "../lib/dashboard-settings.js" - ); - - const settings = await loadDashboardDisplaySettings(); - expect(settings).toEqual(DEFAULT_DASHBOARD_DISPLAY_SETTINGS); - }); - - it("saves and reloads settings", async () => { - const { - saveDashboardDisplaySettings, - loadDashboardDisplaySettings, - getDashboardSettingsPath, - } = await import("../lib/dashboard-settings.js"); - - await saveDashboardDisplaySettings({ - showPerAccountRows: false, - showQuotaDetails: true, - showForecastReasons: false, - showRecommendations: true, - showLiveProbeNotes: false, - actionAutoReturnMs: 1_000, - actionPauseOnKey: false, - uiThemePreset: "blue", - uiAccentColor: "cyan", - }); - - const reloaded = await loadDashboardDisplaySettings(); - expect(reloaded).toEqual({ - showPerAccountRows: false, - showQuotaDetails: true, - showForecastReasons: false, - showRecommendations: true, - showLiveProbeNotes: false, - actionAutoReturnMs: 1_000, - actionPauseOnKey: false, - menuAutoFetchLimits: true, - menuSortEnabled: true, - menuSortMode: "ready-first", - menuSortPinCurrent: false, - menuSortQuickSwitchVisibleRow: true, - uiThemePreset: "blue", - uiAccentColor: "cyan", - menuShowStatusBadge: true, - menuShowCurrentBadge: true, - menuShowLastUsed: true, - menuShowQuotaSummary: true, - menuShowQuotaCooldown: true, - menuShowFetchStatus: true, - menuShowDetailsForUnselectedRows: false, - menuLayoutMode: "compact-details", - menuQuotaTtlMs: 300_000, - menuFocusStyle: "row-invert", - menuHighlightCurrentRow: true, - menuStatuslineFields: ["last-used", "limits", "status"], - }); - - const content = await fs.readFile(getDashboardSettingsPath(), "utf8"); - expect(content).toContain("\"version\": 1"); - expect(content).toContain("\"dashboardDisplaySettings\""); - expect(content).not.toContain("\"settings\":"); - }); - - it("preserves plugin config section when saving dashboard settings", async () => { - const { saveUnifiedPluginConfig } = await import("../lib/unified-settings.js"); - const { - saveDashboardDisplaySettings, - getDashboardSettingsPath, - } = await import("../lib/dashboard-settings.js"); - - await saveUnifiedPluginConfig({ codexMode: false }); - await saveDashboardDisplaySettings({ - showPerAccountRows: true, - showQuotaDetails: true, - showForecastReasons: true, - showRecommendations: true, - showLiveProbeNotes: true, - }); - - const content = await fs.readFile(getDashboardSettingsPath(), "utf8"); - expect(content).toContain("\"pluginConfig\""); - expect(content).toContain("\"codexMode\": false"); - expect(content).toContain("\"dashboardDisplaySettings\""); - }); - - it("migrates legacy dashboard-settings.json into unified settings", async () => { - const { - loadDashboardDisplaySettings, - getDashboardSettingsPath, - } = await import("../lib/dashboard-settings.js"); - - const legacyPath = join(tempDir, "dashboard-settings.json"); - await fs.writeFile( - legacyPath, - JSON.stringify({ - settings: { - showPerAccountRows: false, - showQuotaDetails: false, - menuShowQuotaSummary: false, - menuLayoutMode: "expanded-rows", - }, - }), - "utf-8", - ); - - const migrated = await loadDashboardDisplaySettings(); - expect(migrated.showPerAccountRows).toBe(false); - expect(migrated.showQuotaDetails).toBe(false); - expect(migrated.menuShowQuotaSummary).toBe(false); - expect(migrated.menuLayoutMode).toBe("expanded-rows"); - - const unifiedContent = await fs.readFile(getDashboardSettingsPath(), "utf8"); - expect(unifiedContent).toContain("\"dashboardDisplaySettings\""); - expect(unifiedContent).toContain("\"showPerAccountRows\": false"); - }); - - it("falls back to defaults when legacy file read fails", async () => { - const { loadDashboardDisplaySettings, DEFAULT_DASHBOARD_DISPLAY_SETTINGS } = await import( - "../lib/dashboard-settings.js" - ); - - const legacyPath = join(tempDir, "dashboard-settings.json"); - await fs.writeFile(legacyPath, JSON.stringify({ settings: { showPerAccountRows: false } }), "utf8"); - - const error = Object.assign(new Error("permission denied"), { code: "EACCES" }); - const readSpy = vi.spyOn(fs, "readFile").mockRejectedValueOnce(error); - - const loaded = await loadDashboardDisplaySettings(); - expect(loaded).toEqual(DEFAULT_DASHBOARD_DISPLAY_SETTINGS); - readSpy.mockRestore(); - }); - - it("falls back to defaults when legacy file contains malformed JSON", async () => { - const { loadDashboardDisplaySettings, DEFAULT_DASHBOARD_DISPLAY_SETTINGS } = await import( - "../lib/dashboard-settings.js" - ); - const legacyPath = join(tempDir, "dashboard-settings.json"); - await fs.writeFile(legacyPath, "{ malformed", "utf8"); - - const loaded = await loadDashboardDisplaySettings(); - expect(loaded).toEqual(DEFAULT_DASHBOARD_DISPLAY_SETTINGS); - }); - - it("retries transient EBUSY reads for legacy settings and succeeds", async () => { - const { loadDashboardDisplaySettings } = await import("../lib/dashboard-settings.js"); - const legacyPath = join(tempDir, "dashboard-settings.json"); - const payload = JSON.stringify({ - settings: { - showPerAccountRows: false, - menuShowQuotaSummary: false, - }, - }); - await fs.writeFile(legacyPath, payload, "utf8"); - - const originalReadFile = fs.readFile.bind(fs); - const readSpy = vi.spyOn(fs, "readFile"); - const busy = Object.assign(new Error("busy"), { code: "EBUSY" }); - readSpy - .mockRejectedValueOnce(busy) - .mockImplementation(async (...args) => originalReadFile(...args)); - - const loaded = await loadDashboardDisplaySettings(); - expect(loaded.showPerAccountRows).toBe(false); - expect(loaded.menuShowQuotaSummary).toBe(false); - expect(readSpy).toHaveBeenCalled(); - readSpy.mockRestore(); - }); - - it("falls back to defaults when retryable legacy reads keep failing", async () => { - const { loadDashboardDisplaySettings, DEFAULT_DASHBOARD_DISPLAY_SETTINGS } = await import( - "../lib/dashboard-settings.js" - ); - const legacyPath = join(tempDir, "dashboard-settings.json"); - await fs.writeFile(legacyPath, JSON.stringify({ settings: { showPerAccountRows: false } }), "utf8"); - - const readSpy = vi.spyOn(fs, "readFile"); - const locked = Object.assign(new Error("locked"), { code: "EPERM" }); - readSpy.mockRejectedValue(locked); - - const loaded = await loadDashboardDisplaySettings(); - expect(loaded).toEqual(DEFAULT_DASHBOARD_DISPLAY_SETTINGS); - expect(readSpy).toHaveBeenCalledTimes(4); - readSpy.mockRestore(); - }); + let tempDir: string; + let originalDir: string | undefined; + + beforeEach(async () => { + originalDir = process.env.CODEX_MULTI_AUTH_DIR; + tempDir = await fs.mkdtemp(join(tmpdir(), "codex-multi-auth-dashboard-")); + process.env.CODEX_MULTI_AUTH_DIR = tempDir; + vi.resetModules(); + }); + + afterEach(async () => { + if (originalDir === undefined) { + delete process.env.CODEX_MULTI_AUTH_DIR; + } else { + process.env.CODEX_MULTI_AUTH_DIR = originalDir; + } + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it("loads defaults when settings file does not exist", async () => { + const { loadDashboardDisplaySettings, DEFAULT_DASHBOARD_DISPLAY_SETTINGS } = + await import("../lib/dashboard-settings.js"); + + const settings = await loadDashboardDisplaySettings(); + expect(settings).toEqual(DEFAULT_DASHBOARD_DISPLAY_SETTINGS); + }); + + it("saves and reloads settings", async () => { + const { + saveDashboardDisplaySettings, + loadDashboardDisplaySettings, + getDashboardSettingsPath, + } = await import("../lib/dashboard-settings.js"); + + await saveDashboardDisplaySettings({ + showPerAccountRows: false, + showQuotaDetails: true, + showForecastReasons: false, + showRecommendations: true, + showLiveProbeNotes: false, + actionAutoReturnMs: 1_000, + actionPauseOnKey: false, + uiThemePreset: "blue", + uiAccentColor: "cyan", + }); + + const reloaded = await loadDashboardDisplaySettings(); + expect(reloaded).toEqual({ + showPerAccountRows: false, + showQuotaDetails: true, + showForecastReasons: false, + showRecommendations: true, + showLiveProbeNotes: false, + actionAutoReturnMs: 1_000, + actionPauseOnKey: false, + menuAutoFetchLimits: true, + menuSortEnabled: true, + menuSortMode: "ready-first", + menuSortPinCurrent: false, + menuSortQuickSwitchVisibleRow: true, + uiThemePreset: "blue", + uiAccentColor: "cyan", + menuShowStatusBadge: true, + menuShowCurrentBadge: true, + menuShowLastUsed: true, + menuShowQuotaSummary: true, + menuShowQuotaCooldown: true, + menuShowFetchStatus: true, + menuShowDetailsForUnselectedRows: false, + menuLayoutMode: "compact-details", + menuQuotaTtlMs: 300_000, + menuFocusStyle: "row-invert", + menuHighlightCurrentRow: true, + menuStatuslineFields: ["last-used", "limits", "status"], + }); + + const content = await fs.readFile(getDashboardSettingsPath(), "utf8"); + expect(content).toContain('"version": 1'); + expect(content).toContain('"dashboardDisplaySettings"'); + expect(content).not.toContain('"settings":'); + }); + + it("preserves plugin config section when saving dashboard settings", async () => { + const { saveUnifiedPluginConfig } = + await import("../lib/unified-settings.js"); + const { saveDashboardDisplaySettings, getDashboardSettingsPath } = + await import("../lib/dashboard-settings.js"); + + await saveUnifiedPluginConfig({ codexMode: false }); + await saveDashboardDisplaySettings({ + showPerAccountRows: true, + showQuotaDetails: true, + showForecastReasons: true, + showRecommendations: true, + showLiveProbeNotes: true, + }); + + const content = await fs.readFile(getDashboardSettingsPath(), "utf8"); + expect(content).toContain('"pluginConfig"'); + expect(content).toContain('"codexMode": false'); + expect(content).toContain('"dashboardDisplaySettings"'); + }); + + it("migrates legacy dashboard-settings.json into unified settings", async () => { + const { loadDashboardDisplaySettings, getDashboardSettingsPath } = + await import("../lib/dashboard-settings.js"); + + const legacyPath = join(tempDir, "dashboard-settings.json"); + await fs.writeFile( + legacyPath, + JSON.stringify({ + settings: { + showPerAccountRows: false, + showQuotaDetails: false, + menuShowQuotaSummary: false, + menuLayoutMode: "expanded-rows", + }, + }), + "utf-8", + ); + + const migrated = await loadDashboardDisplaySettings(); + expect(migrated.showPerAccountRows).toBe(false); + expect(migrated.showQuotaDetails).toBe(false); + expect(migrated.menuShowQuotaSummary).toBe(false); + expect(migrated.menuLayoutMode).toBe("expanded-rows"); + + const unifiedContent = await fs.readFile( + getDashboardSettingsPath(), + "utf8", + ); + expect(unifiedContent).toContain('"dashboardDisplaySettings"'); + expect(unifiedContent).toContain('"showPerAccountRows": false'); + }); + + it("falls back to defaults when legacy file read fails", async () => { + const { loadDashboardDisplaySettings, DEFAULT_DASHBOARD_DISPLAY_SETTINGS } = + await import("../lib/dashboard-settings.js"); + + const legacyPath = join(tempDir, "dashboard-settings.json"); + await fs.writeFile( + legacyPath, + JSON.stringify({ settings: { showPerAccountRows: false } }), + "utf8", + ); + + const error = Object.assign(new Error("permission denied"), { + code: "EACCES", + }); + const readSpy = vi.spyOn(fs, "readFile").mockRejectedValueOnce(error); + + const loaded = await loadDashboardDisplaySettings(); + expect(loaded).toEqual(DEFAULT_DASHBOARD_DISPLAY_SETTINGS); + readSpy.mockRestore(); + }); + + it("falls back to defaults when legacy file contains malformed JSON", async () => { + const { loadDashboardDisplaySettings, DEFAULT_DASHBOARD_DISPLAY_SETTINGS } = + await import("../lib/dashboard-settings.js"); + const legacyPath = join(tempDir, "dashboard-settings.json"); + await fs.writeFile(legacyPath, "{ malformed", "utf8"); + + const loaded = await loadDashboardDisplaySettings(); + expect(loaded).toEqual(DEFAULT_DASHBOARD_DISPLAY_SETTINGS); + }); + + it("retries transient EBUSY reads for legacy settings and succeeds", async () => { + const { loadDashboardDisplaySettings } = + await import("../lib/dashboard-settings.js"); + const legacyPath = join(tempDir, "dashboard-settings.json"); + const payload = JSON.stringify({ + settings: { + showPerAccountRows: false, + menuShowQuotaSummary: false, + }, + }); + await fs.writeFile(legacyPath, payload, "utf8"); + + const originalReadFile = fs.readFile.bind(fs); + const readSpy = vi.spyOn(fs, "readFile"); + const busy = Object.assign(new Error("busy"), { code: "EBUSY" }); + readSpy + .mockRejectedValueOnce(busy) + .mockImplementation(async (...args) => originalReadFile(...args)); + + const loaded = await loadDashboardDisplaySettings(); + expect(loaded.showPerAccountRows).toBe(false); + expect(loaded.menuShowQuotaSummary).toBe(false); + expect(readSpy).toHaveBeenCalled(); + readSpy.mockRestore(); + }); + + it("falls back to defaults when retryable legacy reads keep failing", async () => { + const { loadDashboardDisplaySettings, DEFAULT_DASHBOARD_DISPLAY_SETTINGS } = + await import("../lib/dashboard-settings.js"); + const legacyPath = join(tempDir, "dashboard-settings.json"); + await fs.writeFile( + legacyPath, + JSON.stringify({ settings: { showPerAccountRows: false } }), + "utf8", + ); + + const readSpy = vi.spyOn(fs, "readFile"); + const locked = Object.assign(new Error("locked"), { code: "EPERM" }); + readSpy.mockRejectedValue(locked); + + const loaded = await loadDashboardDisplaySettings(); + expect(loaded).toEqual(DEFAULT_DASHBOARD_DISPLAY_SETTINGS); + expect(readSpy).toHaveBeenCalledTimes(4); + readSpy.mockRestore(); + }); + it("normalizes invalid primitive values to defaults", async () => { + const { + normalizeDashboardDisplaySettings, + DEFAULT_DASHBOARD_DISPLAY_SETTINGS, + } = await import("../lib/dashboard-settings.js"); + + const normalized = normalizeDashboardDisplaySettings({ + showPerAccountRows: "nope", + showQuotaDetails: "nope", + showForecastReasons: "nope", + showRecommendations: "nope", + showLiveProbeNotes: "nope", + actionAutoReturnMs: Number.NaN, + actionPauseOnKey: "nope", + menuAutoFetchLimits: "nope", + menuSortEnabled: "nope", + menuSortMode: "invalid", + menuSortPinCurrent: "nope", + menuSortQuickSwitchVisibleRow: "nope", + uiThemePreset: "invalid", + uiAccentColor: "invalid", + menuShowStatusBadge: "nope", + menuShowCurrentBadge: "nope", + menuShowLastUsed: "nope", + menuShowQuotaSummary: "nope", + menuShowQuotaCooldown: "nope", + menuShowFetchStatus: "nope", + menuLayoutMode: "invalid", + menuQuotaTtlMs: Number.POSITIVE_INFINITY, + menuFocusStyle: "invalid", + menuHighlightCurrentRow: "nope", + menuStatuslineFields: "invalid", + }); + + expect(normalized).toEqual(DEFAULT_DASHBOARD_DISPLAY_SETTINGS); + expect(normalizeDashboardDisplaySettings(null)).toEqual( + DEFAULT_DASHBOARD_DISPLAY_SETTINGS, + ); + }); + + it("normalizes enum values, clamped numbers, and deduplicated statusline fields", async () => { + const { normalizeDashboardDisplaySettings } = + await import("../lib/dashboard-settings.js"); + + const normalized = normalizeDashboardDisplaySettings({ + showPerAccountRows: false, + showQuotaDetails: true, + showForecastReasons: false, + showRecommendations: true, + showLiveProbeNotes: false, + actionAutoReturnMs: 12_345, + actionPauseOnKey: false, + menuAutoFetchLimits: false, + menuSortEnabled: true, + menuSortMode: "manual", + menuSortPinCurrent: true, + menuSortQuickSwitchVisibleRow: false, + uiThemePreset: "blue", + uiAccentColor: "yellow", + menuShowStatusBadge: false, + menuShowCurrentBadge: false, + menuShowLastUsed: false, + menuShowQuotaSummary: false, + menuShowQuotaCooldown: false, + menuShowFetchStatus: false, + menuShowDetailsForUnselectedRows: true, + menuLayoutMode: "expanded-rows", + menuQuotaTtlMs: 42_000, + menuFocusStyle: "row-invert", + menuHighlightCurrentRow: false, + menuStatuslineFields: [ + "status", + "status", + "limits", + "unknown", + 100, + "last-used", + ], + }); + + expect(normalized.actionAutoReturnMs).toBe(10_000); + expect(normalized.menuQuotaTtlMs).toBe(60_000); + expect(normalized.menuSortMode).toBe("manual"); + expect(normalized.uiThemePreset).toBe("blue"); + expect(normalized.uiAccentColor).toBe("yellow"); + expect(normalized.menuLayoutMode).toBe("expanded-rows"); + expect(normalized.menuShowDetailsForUnselectedRows).toBe(true); + expect(normalized.menuStatuslineFields).toEqual([ + "status", + "limits", + "last-used", + ]); + }); + + it("falls back to defaults when legacy file parses to non-record JSON", async () => { + const { loadDashboardDisplaySettings, DEFAULT_DASHBOARD_DISPLAY_SETTINGS } = + await import("../lib/dashboard-settings.js"); + const legacyPath = join(tempDir, "dashboard-settings.json"); + await fs.writeFile( + legacyPath, + JSON.stringify(["not", "an", "object"]), + "utf8", + ); + + const loaded = await loadDashboardDisplaySettings(); + expect(loaded).toEqual(DEFAULT_DASHBOARD_DISPLAY_SETTINGS); + }); + it("uses hard fallback literals when optional defaults are undefined", async () => { + const dashboardModule = await import("../lib/dashboard-settings.js"); + const defaults = dashboardModule.DEFAULT_DASHBOARD_DISPLAY_SETTINGS; + const original = { + actionAutoReturnMs: defaults.actionAutoReturnMs, + actionPauseOnKey: defaults.actionPauseOnKey, + menuAutoFetchLimits: defaults.menuAutoFetchLimits, + menuSortEnabled: defaults.menuSortEnabled, + menuSortMode: defaults.menuSortMode, + menuSortPinCurrent: defaults.menuSortPinCurrent, + menuSortQuickSwitchVisibleRow: defaults.menuSortQuickSwitchVisibleRow, + menuShowStatusBadge: defaults.menuShowStatusBadge, + menuShowCurrentBadge: defaults.menuShowCurrentBadge, + menuShowLastUsed: defaults.menuShowLastUsed, + menuShowQuotaSummary: defaults.menuShowQuotaSummary, + menuShowQuotaCooldown: defaults.menuShowQuotaCooldown, + menuShowFetchStatus: defaults.menuShowFetchStatus, + menuQuotaTtlMs: defaults.menuQuotaTtlMs, + menuHighlightCurrentRow: defaults.menuHighlightCurrentRow, + menuStatuslineFields: defaults.menuStatuslineFields, + }; + + defaults.actionAutoReturnMs = undefined; + defaults.actionPauseOnKey = undefined; + defaults.menuAutoFetchLimits = undefined; + defaults.menuSortEnabled = undefined; + defaults.menuSortMode = undefined; + defaults.menuSortPinCurrent = undefined; + defaults.menuSortQuickSwitchVisibleRow = undefined; + defaults.menuShowStatusBadge = undefined; + defaults.menuShowCurrentBadge = undefined; + defaults.menuShowLastUsed = undefined; + defaults.menuShowQuotaSummary = undefined; + defaults.menuShowQuotaCooldown = undefined; + defaults.menuShowFetchStatus = undefined; + defaults.menuQuotaTtlMs = undefined; + defaults.menuHighlightCurrentRow = undefined; + defaults.menuStatuslineFields = undefined; + + try { + const normalized = dashboardModule.normalizeDashboardDisplaySettings({ + actionAutoReturnMs: "bad", + actionPauseOnKey: "bad", + menuAutoFetchLimits: "bad", + menuSortEnabled: "bad", + menuSortMode: "bad", + menuSortPinCurrent: "bad", + menuSortQuickSwitchVisibleRow: "bad", + menuShowStatusBadge: "bad", + menuShowCurrentBadge: "bad", + menuShowLastUsed: "bad", + menuShowQuotaSummary: "bad", + menuShowQuotaCooldown: "bad", + menuShowFetchStatus: "bad", + menuQuotaTtlMs: "bad", + menuHighlightCurrentRow: "bad", + uiAccentColor: "blue", + menuStatuslineFields: "not-array", + }); + + expect(normalized.actionAutoReturnMs).toBe(2_000); + expect(normalized.actionPauseOnKey).toBe(true); + expect(normalized.menuAutoFetchLimits).toBe(true); + expect(normalized.menuSortEnabled).toBe(false); + expect(normalized.menuSortMode).toBe("ready-first"); + expect(normalized.menuSortPinCurrent).toBe(true); + expect(normalized.menuSortQuickSwitchVisibleRow).toBe(true); + expect(normalized.menuShowStatusBadge).toBe(true); + expect(normalized.menuShowCurrentBadge).toBe(true); + expect(normalized.menuShowLastUsed).toBe(true); + expect(normalized.menuShowQuotaSummary).toBe(true); + expect(normalized.menuShowQuotaCooldown).toBe(true); + expect(normalized.menuShowFetchStatus).toBe(true); + expect(normalized.menuQuotaTtlMs).toBe(300_000); + expect(normalized.menuHighlightCurrentRow).toBe(true); + expect(normalized.menuStatuslineFields).toEqual([]); + expect(normalized.uiAccentColor).toBe("blue"); + } finally { + defaults.actionAutoReturnMs = original.actionAutoReturnMs; + defaults.actionPauseOnKey = original.actionPauseOnKey; + defaults.menuAutoFetchLimits = original.menuAutoFetchLimits; + defaults.menuSortEnabled = original.menuSortEnabled; + defaults.menuSortMode = original.menuSortMode; + defaults.menuSortPinCurrent = original.menuSortPinCurrent; + defaults.menuSortQuickSwitchVisibleRow = + original.menuSortQuickSwitchVisibleRow; + defaults.menuShowStatusBadge = original.menuShowStatusBadge; + defaults.menuShowCurrentBadge = original.menuShowCurrentBadge; + defaults.menuShowLastUsed = original.menuShowLastUsed; + defaults.menuShowQuotaSummary = original.menuShowQuotaSummary; + defaults.menuShowQuotaCooldown = original.menuShowQuotaCooldown; + defaults.menuShowFetchStatus = original.menuShowFetchStatus; + defaults.menuQuotaTtlMs = original.menuQuotaTtlMs; + defaults.menuHighlightCurrentRow = original.menuHighlightCurrentRow; + defaults.menuStatuslineFields = original.menuStatuslineFields; + } + }); + + it("logs stringified legacy read failures thrown as non-Error values", async () => { + vi.resetModules(); + const warnMock = vi.fn(); + vi.doMock("../lib/logger.js", () => ({ + logWarn: warnMock, + })); + + try { + const { + loadDashboardDisplaySettings, + DEFAULT_DASHBOARD_DISPLAY_SETTINGS, + } = await import("../lib/dashboard-settings.js"); + + const legacyPath = join(tempDir, "dashboard-settings.json"); + await fs.writeFile( + legacyPath, + JSON.stringify({ settings: { showPerAccountRows: false } }), + "utf8", + ); + + const readSpy = vi.spyOn(fs, "readFile"); + readSpy.mockRejectedValueOnce("legacy-read-string-failure"); + + const loaded = await loadDashboardDisplaySettings(); + expect(loaded).toEqual(DEFAULT_DASHBOARD_DISPLAY_SETTINGS); + expect( + warnMock.mock.calls.some((args) => + String(args[0]).includes("legacy-read-string-failure"), + ), + ).toBe(true); + + readSpy.mockRestore(); + } finally { + vi.doUnmock("../lib/logger.js"); + } + }); }); diff --git a/test/documentation.test.ts b/test/documentation.test.ts index cbced9b3..a11a47ed 100644 --- a/test/documentation.test.ts +++ b/test/documentation.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest'; import { execFileSync } from 'node:child_process'; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; const projectRoot = resolve(process.cwd()); @@ -16,8 +16,13 @@ const userDocs = [ 'docs/privacy.md', 'docs/upgrade.md', 'docs/reference/commands.md', + 'docs/reference/public-api.md', + 'docs/reference/error-contracts.md', 'docs/reference/settings.md', 'docs/reference/storage-paths.md', + 'docs/releases/v0.1.4.md', + 'docs/releases/v0.1.3.md', + 'docs/releases/v0.1.2.md', 'docs/releases/v0.1.1.md', 'docs/releases/v0.1.0.md', 'docs/releases/v0.1.0-beta.0.md', @@ -33,6 +38,12 @@ const scopedLegacyAllowedFiles = new Set([ 'docs/releases/v0.1.0-beta.0.md', ]); +const compatibilityAliasAllowedFiles = new Set([ + 'docs/reference/commands.md', + 'docs/troubleshooting.md', + 'docs/upgrade.md', +]); + function read(filePath: string): string { return readFileSync(join(projectRoot, filePath), 'utf-8'); } @@ -43,6 +54,27 @@ function extractInternalLinks(markdown: string): string[] { .filter((link) => !link.startsWith('http') && !link.startsWith('#')); } +function listMarkdownFiles(rootDir: string): string[] { + const entries = readdirSync(rootDir, { withFileTypes: true }) + .sort((left, right) => left.name.localeCompare(right.name)); + const markdownFiles: string[] = []; + for (const entry of entries) { + const absolutePath = join(rootDir, entry.name); + if (entry.isDirectory()) { + markdownFiles.push(...listMarkdownFiles(absolutePath)); + continue; + } + if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) { + markdownFiles.push(absolutePath); + } + } + return markdownFiles.sort((left, right) => left.localeCompare(right)); +} + +function isExternalOrUriSchemeLink(linkPath: string): boolean { + return /^[a-z][a-z0-9+.-]*:/i.test(linkPath) || linkPath.startsWith('//'); +} + function compareSemverDescending(left: string, right: string): number { const leftParts = left.split('.').map((part) => Number.parseInt(part, 10)); const rightParts = right.split('.').map((part) => Number.parseInt(part, 10)); @@ -67,10 +99,14 @@ describe('Documentation Integrity', () => { it('docs portal links to stable, beta, and archived release history', () => { const portal = read('docs/README.md'); - expect(portal).toContain('releases/v0.1.1.md'); - expect(portal).toContain('releases/v0.1.0.md'); + expect(portal).toContain('reference/public-api.md'); + expect(portal).toContain('reference/error-contracts.md'); + expect(portal).toContain('releases/v0.1.4.md'); + expect(portal).toContain('releases/v0.1.3.md'); + expect(portal).toContain('releases/v0.1.2.md'); expect(portal).toContain('releases/v0.1.0-beta.0.md'); expect(portal).toContain('releases/legacy-pre-0.1-history.md'); + expect(portal).toContain('| [User Guides release notes](#user-guides) | Stable, previous, and archived release notes |'); const beta = read('docs/releases/v0.1.0-beta.0.md'); expect(beta).toContain('Archived'); @@ -117,6 +153,22 @@ describe('Documentation Integrity', () => { } }); + it('keeps compatibility command aliases scoped to reference, troubleshooting, or migration docs', () => { + const files = ['README.md', ...userDocs]; + const aliasPattern = /\bcodex (multi auth|multi-auth|multiauth)\b/i; + + for (const filePath of files) { + const content = read(filePath); + const hasAlias = aliasPattern.test(content); + if (hasAlias) { + expect( + compatibilityAliasAllowedFiles.has(filePath), + `${filePath} should not include compatibility alias commands`, + ).toBe(true); + } + } + }); + it('keeps codex auth as the command standard in key docs', () => { const keyDocs = [ 'README.md', @@ -134,6 +186,29 @@ describe('Documentation Integrity', () => { } }); + it('documents public API stability tiers and error contracts', () => { + const publicApi = read('docs/reference/public-api.md').toLowerCase(); + const errorContracts = read('docs/reference/error-contracts.md').toLowerCase(); + + expect(publicApi).toContain('tier a'); + expect(publicApi).toContain('tier b'); + expect(publicApi).toContain('tier c'); + expect(publicApi).toContain('options-object'); + expect(publicApi).toContain('semver'); + + expect(errorContracts).toContain('exit codes'); + expect(errorContracts).toContain('json mode contract'); + expect(errorContracts).toContain('entitlement'); + expect(errorContracts).toContain('rate-limit'); + expect(errorContracts).toContain('options-object compatibility contract'); + expect(errorContracts).toContain('selecthybridaccount'); + expect(errorContracts).toContain('exponentialbackoff'); + expect(errorContracts).toContain('gettopcandidates'); + expect(errorContracts).toContain('createcodexheaders'); + expect(errorContracts).toContain('getratelimitbackoffwithreason'); + expect(errorContracts).toContain('transformrequestbody'); + }); + it('keeps fix command flag docs aligned across README, reference, and CLI usage text', () => { const readme = read('README.md'); const commandRef = read('docs/reference/commands.md'); @@ -144,7 +219,10 @@ describe('Documentation Integrity', () => { expect(readme).toContain('codex auth fix --live --model gpt-5-codex'); expect(commandRef).toContain('| `--live` | forecast, report, fix |'); expect(commandRef).toContain('| `--model ` | forecast, report, fix |'); - expect(manager).toContain('codex-multi-auth auth fix [--dry-run] [--json] [--live] [--model ]'); + expect(manager).toContain('codex auth login'); + expect(manager).toContain('codex auth fix [--dry-run] [--json] [--live] [--model ]'); + expect(manager).toContain('Missing index. Usage: codex auth switch '); + expect(manager).not.toContain('codex-multi-auth auth switch '); }); it('documents stable overrides separately from advanced and internal overrides', () => { @@ -280,18 +358,70 @@ describe('Documentation Integrity', () => { } }); - it('has valid internal links in docs/README.md', () => { - const content = read('docs/README.md'); - const links = extractInternalLinks(content); + it('ignores URI scheme links during docs link validation', () => { + const tempDocsRoot = mkdtempSync(join(tmpdir(), 'codex-doc-links-')); - for (const link of links) { - const cleanPath = link.split('#')[0]; - if (!cleanPath) { - continue; - } - expect(existsSync(join(projectRoot, 'docs', cleanPath)), `Missing docs link: ${cleanPath}`).toBe( - true, + try { + const nestedDir = join(tempDocsRoot, 'nested'); + mkdirSync(nestedDir, { recursive: true }); + writeFileSync( + join(tempDocsRoot, 'index.md'), + [ + '# Temporary docs', + '[Guide](./nested/guide.md)', + '[Mail](mailto:support@example.com)', + '[Phone](tel:+1234567890)', + '[Scheme relative](//example.com/path)', + ].join('\n'), + 'utf-8', ); + writeFileSync(join(nestedDir, 'guide.md'), '# Guide\n', 'utf-8'); + + const docsMarkdownFiles = listMarkdownFiles(tempDocsRoot); + const missingTargets: string[] = []; + + for (const filePath of docsMarkdownFiles) { + const content = readFileSync(filePath, 'utf-8'); + const links = extractInternalLinks(content); + for (const link of links) { + const cleanPath = link.split('#')[0]; + if (!cleanPath || isExternalOrUriSchemeLink(cleanPath)) { + continue; + } + const targetPath = resolve(dirname(filePath), cleanPath); + if (!existsSync(targetPath)) { + missingTargets.push(`${filePath}: ${cleanPath}`); + } + } + } + + expect(missingTargets).toEqual([]); + } finally { + rmSync(tempDocsRoot, { recursive: true, force: true }); + } + }); + + it('has valid internal links in markdown files under docs/', () => { + const docsRoot = join(projectRoot, 'docs'); + const docsMarkdownFiles = listMarkdownFiles(docsRoot); + + for (const filePath of docsMarkdownFiles) { + const content = readFileSync(filePath, 'utf-8'); + const links = extractInternalLinks(content); + for (const link of links) { + const cleanPath = link.split('#')[0]; + if (!cleanPath) { + continue; + } + if (isExternalOrUriSchemeLink(cleanPath)) { + continue; + } + const targetPath = resolve(dirname(filePath), cleanPath); + expect( + existsSync(targetPath), + `Missing docs link in ${filePath.replace(projectRoot, '')}: ${cleanPath}`, + ).toBe(true); + } } }); }); diff --git a/test/entitlement-cache.test.ts b/test/entitlement-cache.test.ts index 91bfacaf..77b83dfe 100644 --- a/test/entitlement-cache.test.ts +++ b/test/entitlement-cache.test.ts @@ -1,73 +1,227 @@ import { describe, expect, it } from "vitest"; -import { EntitlementCache, resolveEntitlementAccountKey } from "../lib/entitlement-cache.js"; +import { + EntitlementCache, + resolveEntitlementAccountKey, +} from "../lib/entitlement-cache.js"; describe("entitlement cache", () => { - it("resolves account key priority", () => { - expect( - resolveEntitlementAccountKey({ - accountId: "acc_123", - email: "user@example.com", - index: 2, - }), - ).toBe("id:acc_123"); - expect(resolveEntitlementAccountKey({ email: "User@Example.com", index: 5 })).toBe( - "email:user@example.com", - ); - expect(resolveEntitlementAccountKey({ index: 7 })).toBe("idx:7"); - }); - - it("marks model block and expires after ttl", () => { - const cache = new EntitlementCache(); - const accountKey = "id:acc_1"; - cache.markBlocked(accountKey, "gpt-5.3-codex", "unsupported-model", 500, 1_000); - - const blockedNow = cache.isBlocked(accountKey, "gpt-5.3-codex", 1_100); - expect(blockedNow.blocked).toBe(true); - expect(blockedNow.reason).toBe("unsupported-model"); - expect(blockedNow.waitMs).toBeGreaterThan(0); - - const blockedLater = cache.isBlocked(accountKey, "gpt-5.3-codex", 2_200); - expect(blockedLater.blocked).toBe(false); - expect(blockedLater.waitMs).toBe(0); - }); - - it("clears model or full account block", () => { - const cache = new EntitlementCache(); - const accountKey = "email:person@example.com"; - cache.markBlocked(accountKey, "gpt-5-codex", "plan-entitlement", 5_000, 2_000); - cache.markBlocked(accountKey, "gpt-5.3-codex", "unsupported-model", 5_000, 2_000); - - cache.clear(accountKey, "gpt-5-codex"); - expect(cache.isBlocked(accountKey, "gpt-5-codex", 2_500).blocked).toBe(false); - expect(cache.isBlocked(accountKey, "gpt-5.3-codex", 2_500).blocked).toBe(true); - - cache.clear(accountKey); - expect(cache.isBlocked(accountKey, "gpt-5.3-codex", 2_500).blocked).toBe(false); - }); - - it("normalizes invalid ttl values to default minimum behavior", () => { - const cache = new EntitlementCache(); - const accountKey = "id:ttl-invalid"; - cache.markBlocked(accountKey, "gpt-5-codex", "plan-entitlement", Number.NaN, 1_000); - - const blocked = cache.isBlocked(accountKey, "gpt-5-codex", 2_000); - expect(blocked.blocked).toBe(true); - expect(blocked.waitMs).toBeGreaterThan(0); - }); - - it("returns immutable snapshot entries", () => { - const cache = new EntitlementCache(); - const accountKey = "id:snapshot"; - cache.markBlocked(accountKey, "gpt-5-codex", "plan-entitlement", 5_000, 1_000); - - const snapshot = cache.snapshot(1_500); - expect(snapshot.accounts[accountKey]).toHaveLength(1); - if (!snapshot.accounts[accountKey]) { - throw new Error("missing snapshot account entry"); - } - - snapshot.accounts[accountKey][0].model = "tampered-model"; - const fresh = cache.snapshot(1_500); - expect(fresh.accounts[accountKey]?.[0]?.model).toBe("gpt-5-codex"); - }); + it("resolves account key priority", () => { + expect( + resolveEntitlementAccountKey({ + accountId: "acc_123", + email: "user@example.com", + index: 2, + }), + ).toBe("id:acc_123"); + expect( + resolveEntitlementAccountKey({ email: "User@Example.com", index: 5 }), + ).toBe("email:user@example.com"); + expect(resolveEntitlementAccountKey({ index: 7 })).toBe("idx:7"); + }); + + it("marks model block and expires after ttl", () => { + const cache = new EntitlementCache(); + const accountKey = "id:acc_1"; + cache.markBlocked( + accountKey, + "gpt-5.3-codex", + "unsupported-model", + 500, + 1_000, + ); + + const blockedNow = cache.isBlocked(accountKey, "gpt-5.3-codex", 1_100); + expect(blockedNow.blocked).toBe(true); + expect(blockedNow.reason).toBe("unsupported-model"); + expect(blockedNow.waitMs).toBeGreaterThan(0); + + const blockedLater = cache.isBlocked(accountKey, "gpt-5.3-codex", 2_200); + expect(blockedLater.blocked).toBe(false); + expect(blockedLater.waitMs).toBe(0); + }); + + it("clears model or full account block", () => { + const cache = new EntitlementCache(); + const accountKey = "email:person@example.com"; + cache.markBlocked( + accountKey, + "gpt-5-codex", + "plan-entitlement", + 5_000, + 2_000, + ); + cache.markBlocked( + accountKey, + "gpt-5.3-codex", + "unsupported-model", + 5_000, + 2_000, + ); + + cache.clear(accountKey, "gpt-5-codex"); + expect(cache.isBlocked(accountKey, "gpt-5-codex", 2_500).blocked).toBe( + false, + ); + expect(cache.isBlocked(accountKey, "gpt-5.3-codex", 2_500).blocked).toBe( + true, + ); + + cache.clear(accountKey); + expect(cache.isBlocked(accountKey, "gpt-5.3-codex", 2_500).blocked).toBe( + false, + ); + }); + + it("normalizes invalid ttl values to default minimum behavior", () => { + const cache = new EntitlementCache(); + const accountKey = "id:ttl-invalid"; + cache.markBlocked( + accountKey, + "gpt-5-codex", + "plan-entitlement", + Number.NaN, + 1_000, + ); + + const blocked = cache.isBlocked(accountKey, "gpt-5-codex", 2_000); + expect(blocked.blocked).toBe(true); + expect(blocked.waitMs).toBeGreaterThan(0); + }); + + it("returns immutable snapshot entries", () => { + const cache = new EntitlementCache(); + const accountKey = "id:snapshot"; + cache.markBlocked( + accountKey, + "gpt-5-codex", + "plan-entitlement", + 5_000, + 1_000, + ); + + const snapshot = cache.snapshot(1_500); + expect(snapshot.accounts[accountKey]).toHaveLength(1); + if (!snapshot.accounts[accountKey]) { + throw new Error("missing snapshot account entry"); + } + + snapshot.accounts[accountKey][0].model = "tampered-model"; + const fresh = cache.snapshot(1_500); + expect(fresh.accounts[accountKey]?.[0]?.model).toBe("gpt-5-codex"); + }); + it("handles trimmed/empty account refs and non-finite indexes", () => { + expect(resolveEntitlementAccountKey({ accountId: " acc_trim " })).toBe( + "id:acc_trim", + ); + expect( + resolveEntitlementAccountKey({ email: " Person@Example.com " }), + ).toBe("email:person@example.com"); + expect(resolveEntitlementAccountKey({ index: Number.NaN })).toBe("idx:0"); + }); + + it("ignores invalid mark/clear/isBlocked inputs", () => { + const cache = new EntitlementCache(); + cache.markBlocked("", "gpt-5-codex", "plan-entitlement", 5_000, 1_000); + cache.markBlocked("id:bad-model", " ", "plan-entitlement", 5_000, 1_000); + cache.clear("", "gpt-5-codex"); + cache.clear("id:bad-model", " "); + + expect(cache.snapshot(1_500).accounts).toEqual({}); + expect(cache.isBlocked("", "gpt-5-codex", 1_500)).toEqual({ + blocked: false, + waitMs: 0, + }); + expect(cache.isBlocked("id:missing", "", 1_500)).toEqual({ + blocked: false, + waitMs: 0, + }); + }); + + it("evicts the oldest account bucket when max buckets are exceeded", () => { + const cache = new EntitlementCache(); + for (let index = 0; index < 513; index += 1) { + cache.markBlocked( + `id:acc_${index}`, + "gpt-5-codex", + "plan-entitlement", + 5_000, + 1_000, + ); + } + + expect(cache.isBlocked("id:acc_0", "gpt-5-codex", 1_500).blocked).toBe( + false, + ); + expect(cache.isBlocked("id:acc_1", "gpt-5-codex", 1_500).blocked).toBe( + true, + ); + expect(cache.isBlocked("id:acc_512", "gpt-5-codex", 1_500).blocked).toBe( + true, + ); + }); + + it("normalizes model names with provider prefix and effort suffix", () => { + const cache = new EntitlementCache(); + const accountKey = "id:model-normalize"; + cache.markBlocked( + accountKey, + "OpenAI/GPT-5-CODEX-HIGH", + "unsupported-model", + 5_000, + 1_000, + ); + + expect(cache.isBlocked(accountKey, "gpt-5-codex", 1_500).blocked).toBe( + true, + ); + expect( + cache.isBlocked(accountKey, "openai/gpt-5-codex-low", 1_500).blocked, + ).toBe(true); + }); + + it("prunes expired blocks and removes empty account buckets", () => { + const cache = new EntitlementCache(); + cache.markBlocked( + "id:prune_a", + "gpt-5-codex", + "plan-entitlement", + 500, + 1_000, + ); + cache.markBlocked( + "id:prune_b", + "gpt-5.3-codex", + "unsupported-model", + 500, + 1_000, + ); + + const removed = cache.prune(2_000); + expect(removed).toBe(2); + expect(cache.snapshot(2_000).accounts).toEqual({}); + }); + + it("sorts snapshot blocks alphabetically by normalized model", () => { + const cache = new EntitlementCache(); + const accountKey = "id:sort"; + cache.markBlocked( + accountKey, + "gpt-5.3-codex", + "unsupported-model", + 5_000, + 1_000, + ); + cache.markBlocked( + accountKey, + "gpt-5-codex", + "plan-entitlement", + 5_000, + 1_000, + ); + + const models = + cache.snapshot(1_100).accounts[accountKey]?.map((entry) => entry.model) ?? + []; + expect(models).toEqual(["gpt-5-codex", "gpt-5.3-codex"]); + }); }); diff --git a/test/fetch-helpers.test.ts b/test/fetch-helpers.test.ts index 829726c9..d0c03473 100644 --- a/test/fetch-helpers.test.ts +++ b/test/fetch-helpers.test.ts @@ -17,6 +17,7 @@ import { } from '../lib/request/fetch-helpers.js'; import * as loggerModule from '../lib/logger.js'; import type { Auth } from '../lib/types.js'; +import type { CreateCodexHeadersParams } from '../lib/request/fetch-helpers.js'; import { URL_PATHS, OPENAI_HEADERS, OPENAI_HEADER_VALUES, CODEX_BASE_URL } from '../lib/constants.js'; describe('Fetch Helpers Module', () => { @@ -237,6 +238,41 @@ describe('Fetch Helpers Module', () => { expect(headers.get(OPENAI_HEADERS.SESSION_ID)).toBeNull(); }); + it('supports named-parameter options form', () => { + const positional = createCodexHeaders(undefined, accountId, accessToken, { + model: 'gpt-5', + promptCacheKey: 'session-named', + }); + const named = createCodexHeaders({ + init: undefined, + accountId, + accessToken, + opts: { model: 'gpt-5', promptCacheKey: 'session-named' }, + }); + + expect(named.get('Authorization')).toBe(positional.get('Authorization')); + expect(named.get(OPENAI_HEADERS.ACCOUNT_ID)).toBe(positional.get(OPENAI_HEADERS.ACCOUNT_ID)); + expect(named.get(OPENAI_HEADERS.SESSION_ID)).toBe(positional.get(OPENAI_HEADERS.SESSION_ID)); + expect(named.get(OPENAI_HEADERS.CONVERSATION_ID)).toBe(positional.get(OPENAI_HEADERS.CONVERSATION_ID)); + expect(named.get(OPENAI_HEADERS.BETA)).toBe(positional.get(OPENAI_HEADERS.BETA)); + expect(named.get(OPENAI_HEADERS.ORIGINATOR)).toBe(positional.get(OPENAI_HEADERS.ORIGINATOR)); + expect(named.get('accept')).toBe(positional.get('accept')); + expect(named.get('content-type')).toBe(positional.get('content-type')); + expect(named.has('x-api-key')).toBe(false); + }); + + it('does not treat RequestInit-like objects as named params when keys are spread accidentally', () => { + const accidentalRequestInit = { + headers: { 'content-type': 'application/json' }, + accountId: 'accidental-account', + accessToken: 'accidental-token', + }; + + expect(() => + createCodexHeaders(accidentalRequestInit as unknown as CreateCodexHeadersParams), + ).toThrow('createCodexHeaders requires accountId and accessToken'); + }); + it('maps usage_not_included 404 to 403 entitlement error, not rate limit', async () => { const body = { error: { @@ -681,14 +717,33 @@ describe('Fetch Helpers Module', () => { expect(rateLimit?.retryAfterMs).toBeGreaterThan(0); }); - it('normalizes small retryAfterMs values as seconds', async () => { - const body = { error: { message: 'rate limited', retry_after_ms: 5 } }; - const response = new Response(JSON.stringify(body), { status: 429 }); - - const { rateLimit } = await handleErrorResponse(response); - - expect(rateLimit?.retryAfterMs).toBe(5000); - }); + it('keeps retry_after_ms values in milliseconds', async () => { + const body = { error: { message: 'rate limited', retry_after_ms: 5 } }; + const response = new Response(JSON.stringify(body), { status: 429 }); + + const { rateLimit } = await handleErrorResponse(response); + + expect(rateLimit?.retryAfterMs).toBe(5); + }); + + it('interprets retry_after values as seconds', async () => { + const body = { error: { message: 'rate limited', retry_after: 5 } }; + const response = new Response(JSON.stringify(body), { status: 429 }); + + const { rateLimit } = await handleErrorResponse(response); + + expect(rateLimit?.retryAfterMs).toBe(5000); + }); + + it('falls back to retry-after headers when body retry values are invalid', async () => { + const body = { error: { message: 'rate limited', retry_after_ms: -1 } }; + const headers = new Headers({ 'retry-after-ms': '2500' }); + const response = new Response(JSON.stringify(body), { status: 429, headers }); + + const { rateLimit } = await handleErrorResponse(response); + + expect(rateLimit?.retryAfterMs).toBe(2500); + }); it('caps retryAfterMs at 5 minutes', async () => { const body = { error: { message: 'rate limited', retry_after_ms: 600000 } }; @@ -838,5 +893,226 @@ describe('Fetch Helpers Module', () => { expect(result?.body.model).toBe('gpt-5.1'); expect(result?.updatedInit).toBeDefined(); }); + describe("additional edge branches", () => { + it("handles unsupported model info for malformed payloads", () => { + expect(getUnsupportedCodexModelInfo(undefined).isUnsupported).toBe(false); + expect( + getUnsupportedCodexModelInfo({ error: "not-an-object" }).isUnsupported, + ).toBe(false); + + const info = getUnsupportedCodexModelInfo({ + error: { + code: 123, + }, + }); + expect(info.code).toBeUndefined(); + expect(info.message).toBeUndefined(); + expect(info.isUnsupported).toBe(false); + }); + + it("resolves unsupported-model fallbacks with custom chains and canonicalization", () => { + const fallback = resolveUnsupportedCodexFallbackModel({ + requestedModel: "org/gpt-5.3-codex-high", + errorBody: { + error: { + code: "model_not_supported_with_chatgpt_account", + message: "not supported when using Codex with a ChatGPT account", + }, + }, + attemptedModels: ["", " ", "gpt-5.3-codex"], + fallbackOnUnsupportedCodexModel: true, + fallbackToGpt52OnUnsupportedGpt53: true, + customChain: { + "": ["gpt-5-codex"], + "gpt-5.3-codex": ["gpt-5.3-codex", "gpt-5-codex-low"], + "bad-entry": "not-an-array" as unknown as string[], + }, + }); + + expect(fallback).toBe("gpt-5-codex"); + }); + + it("returns undefined when fallback is disabled or model cannot be resolved", () => { + const unsupportedError = { + error: { + code: "model_not_supported_with_chatgpt_account", + }, + }; + + expect( + resolveUnsupportedCodexFallbackModel({ + requestedModel: "gpt-5.3-codex", + errorBody: unsupportedError, + fallbackOnUnsupportedCodexModel: false, + fallbackToGpt52OnUnsupportedGpt53: true, + }), + ).toBeUndefined(); + + expect( + resolveUnsupportedCodexFallbackModel({ + requestedModel: undefined, + errorBody: unsupportedError, + fallbackOnUnsupportedCodexModel: true, + fallbackToGpt52OnUnsupportedGpt53: true, + }), + ).toBeUndefined(); + + expect( + resolveUnsupportedCodexFallbackModel({ + requestedModel: "unknown-codex-model", + errorBody: unsupportedError, + fallbackOnUnsupportedCodexModel: true, + fallbackToGpt52OnUnsupportedGpt53: true, + }), + ).toBeUndefined(); + }); + + it("throws when createCodexHeaders receives invalid named-parameter candidates", () => { + expect(() => createCodexHeaders("bad" as unknown as RequestInit)).toThrow( + "createCodexHeaders requires accountId and accessToken", + ); + + expect(() => + createCodexHeaders({ + accountId: "acc", + accessToken: "tok", + extra: "value", + } as unknown as CreateCodexHeadersParams), + ).toThrow("createCodexHeaders requires accountId and accessToken"); + + expect(() => + createCodexHeaders({ + accountId: "acc", + accessToken: 123, + } as unknown as CreateCodexHeadersParams), + ).toThrow("createCodexHeaders requires accountId and accessToken"); + }); + + it("refreshes using API auth without mutating oauth-only fields", async () => { + const auth: Auth = { type: "api", key: "api-key" }; + const client = { auth: { set: vi.fn() } }; + + vi.spyOn(refreshQueueModule, "queuedRefresh").mockResolvedValue({ + type: "success", + access: "new-access", + refresh: "new-refresh", + expires: Date.now() + 60_000, + } as never); + + const updated = await refreshAndUpdateToken(auth, client as never); + expect(updated).toBe(auth); + expect(client.auth.set).toHaveBeenCalledTimes(1); + }); + + it("transforms parsed body when init is undefined", async () => { + const { transformRequestForCodex } = + await import("../lib/request/fetch-helpers.js"); + const result = await transformRequestForCodex( + undefined, + "https://example.com", + { global: {}, models: {} }, + true, + { + model: "gpt-5.3-codex", + input: [{ type: "message", role: "user", content: "hello" }], + }, + ); + + expect(result).toBeDefined(); + expect(typeof result?.updatedInit.body).toBe("string"); + expect(result?.body.model).toBe("gpt-5-codex"); + }); + + it("adds codex login hint for unauthorized top-level, trimmed, statusText, and fallback messages", async () => { + const topLevel = await handleErrorResponse( + new Response(JSON.stringify({ message: "token invalid" }), { + status: 401, + statusText: "Unauthorized", + }), + ); + const topLevelJson = (await topLevel.response.json()) as { + error: { message: string }; + }; + expect(topLevelJson.error.message).toContain("codex login"); + + const trimmed = await handleErrorResponse( + new Response("plain auth failure", { + status: 401, + statusText: "Unauthorized", + }), + ); + const trimmedJson = (await trimmed.response.json()) as { + error: { message: string }; + }; + expect(trimmedJson.error.message).toContain("codex login"); + + const statusText = await handleErrorResponse( + new Response("", { status: 401, statusText: "Unauthorized" }), + ); + const statusTextJson = (await statusText.response.json()) as { + error: { message: string }; + }; + expect(statusTextJson.error.message).toContain("codex login"); + + const fallback = await handleErrorResponse( + new Response("", { status: 401, statusText: "" }), + ); + const fallbackJson = (await fallback.response.json()) as { + error: { message: string }; + }; + expect(fallbackJson.error.message).toContain("codex login"); + }); + + it("does not remap empty 404 bodies to rate limits", async () => { + const { response, rateLimit } = await handleErrorResponse( + new Response("", { status: 404 }), + ); + expect(response.status).toBe(404); + expect(rateLimit).toBeUndefined(); + }); + + it("falls back to default retry window when retry metadata is invalid", async () => { + const pastUnixSeconds = Math.floor(Date.now() / 1000) - 60; + const response = new Response( + JSON.stringify({ + error: { + message: "rate limited", + retry_after_ms: "not-a-number", + retry_after: 0, + resets_at: "also-bad", + }, + }), + { + status: 429, + headers: { + "retry-after-ms": "NaN", + "retry-after": "0", + "x-ratelimit-reset": String(pastUnixSeconds), + }, + }, + ); + + const { rateLimit } = await handleErrorResponse(response); + expect(rateLimit?.retryAfterMs).toBe(60000); + }); + + it("uses generic unsupported model placeholder when body has no quoted model", async () => { + const body = { + detail: + "model is not supported when using Codex with a ChatGPT account", + }; + const response = new Response(JSON.stringify(body), { + status: 400, + statusText: "Bad Request", + }); + + const { response: result } = await handleErrorResponse(response); + const json = (await result.json()) as { + error: { unsupported_model?: string }; + }; + expect(json.error.unsupported_model).toBe("requested model"); + }); }); }); + +}); diff --git a/test/forecast.test.ts b/test/forecast.test.ts index 8a1dec9c..e9759b25 100644 --- a/test/forecast.test.ts +++ b/test/forecast.test.ts @@ -73,7 +73,9 @@ describe("forecast helpers", () => { }); expect(result.riskScore).toBeGreaterThanOrEqual(30); - expect(result.reasons.some((reason) => reason.includes("primary quota"))).toBe(true); + expect( + result.reasons.some((reason) => reason.includes("primary quota")), + ).toBe(true); }); it("recommends the best ready account", () => { @@ -132,7 +134,9 @@ describe("forecast helpers", () => { }, }); - expect(result.reasons.some((reason) => reason.includes("refresh warning:"))).toBe(true); + expect( + result.reasons.some((reason) => reason.includes("refresh warning:")), + ).toBe(true); expect(result.reasons.join(" ")).not.toContain("user@example.com"); expect(result.reasons.join(" ")).not.toContain("verysecrettoken12345"); expect(result.reasons.join(" ")).not.toContain("sk-1234567890abcdef"); @@ -185,8 +189,12 @@ describe("forecast helpers", () => { expect(result.availability).toBe("delayed"); expect(result.waitMs).toBe(rateLimitMs); - expect(result.reasons.some((reason) => reason.includes("cooldown remaining"))).toBe(true); - expect(result.reasons.some((reason) => reason.includes("rate limit resets in"))).toBe(true); + expect( + result.reasons.some((reason) => reason.includes("cooldown remaining")), + ).toBe(true); + expect( + result.reasons.some((reason) => reason.includes("rate limit resets in")), + ).toBe(true); }); it("marks delayed on live 429 and tracks quota reset wait", () => { @@ -218,7 +226,11 @@ describe("forecast helpers", () => { expect(result.availability).toBe("delayed"); expect(result.waitMs).toBe(120_000); - expect(result.reasons.some((reason) => reason.includes("live probe returned 429"))).toBe(true); + expect( + result.reasons.some((reason) => + reason.includes("live probe returned 429"), + ), + ).toBe(true); }); it("does not delay healthy accounts only because quota reset headers exist", () => { @@ -281,7 +293,11 @@ describe("forecast helpers", () => { index: 0, now, isCurrent: false, - account: { refreshToken: "r70", addedAt: now - 1_000, lastUsed: now - 1_000 }, + account: { + refreshToken: "r70", + addedAt: now - 1_000, + lastUsed: now - 1_000, + }, liveQuota: { status: 200, model: "gpt-5-codex", @@ -293,7 +309,11 @@ describe("forecast helpers", () => { index: 1, now, isCurrent: false, - account: { refreshToken: "r80", addedAt: now - 1_000, lastUsed: now - 1_000 }, + account: { + refreshToken: "r80", + addedAt: now - 1_000, + lastUsed: now - 1_000, + }, liveQuota: { status: 200, model: "gpt-5-codex", @@ -305,7 +325,11 @@ describe("forecast helpers", () => { index: 2, now, isCurrent: false, - account: { refreshToken: "r90", addedAt: now - 1_000, lastUsed: now - 1_000 }, + account: { + refreshToken: "r90", + addedAt: now - 1_000, + lastUsed: now - 1_000, + }, liveQuota: { status: 200, model: "gpt-5-codex", @@ -317,7 +341,11 @@ describe("forecast helpers", () => { index: 3, now, isCurrent: false, - account: { refreshToken: "r98", addedAt: now - 1_000, lastUsed: now - 1_000 }, + account: { + refreshToken: "r98", + addedAt: now - 1_000, + lastUsed: now - 1_000, + }, liveQuota: { status: 200, model: "gpt-5-codex", @@ -400,6 +428,123 @@ describe("forecast helpers", () => { expect(recommendation.reason).toContain("No account is immediately ready"); }); + it("uses redacted fallback refresh messages when reason code is blank", () => { + const now = 1_700_000_000_000; + const result = evaluateForecastAccount({ + index: 0, + now, + isCurrent: false, + account: { + refreshToken: "r1", + addedAt: now - 1_000, + lastUsed: now - 1_000, + }, + refreshFailure: { + type: "failed", + reason: " ", + message: + "Bearer supersecrettoken123 for dev@example.com using key sk-1234567890abcdef", + }, + }); + + const joined = result.reasons.join(" "); + expect(joined).toContain("refresh warning:"); + expect(joined).toContain("Bearer ***"); + expect(joined).not.toContain("dev@example.com"); + expect(joined).not.toContain("supersecrettoken123"); + expect(joined).not.toContain("sk-1234567890abcdef"); + }); + + it("uses codex-family reset keys and ignores stale or invalid entries", () => { + const now = 1_700_000_000_000; + const result = evaluateForecastAccount({ + index: 1, + now, + isCurrent: false, + account: { + refreshToken: "r2", + addedAt: now - 1_000, + lastUsed: now - 1_000, + rateLimitResetTimes: { + codex: now - 10, + "codex:5h": now + 60_000, + "codex:7d": "bad" as unknown as number, + "other-family": now + 5_000, + }, + }, + }); + + expect(result.availability).toBe("delayed"); + expect(result.waitMs).toBe(60_000); + }); + + it("keeps unavailable state when live quota returns 429 on an unavailable account", () => { + const now = 1_700_000_000_000; + const result = evaluateForecastAccount({ + index: 2, + now, + isCurrent: false, + account: { + refreshToken: "r3", + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: false, + }, + liveQuota: { + status: 429, + model: "gpt-5-codex", + primary: { + usedPercent: 95, + windowMinutes: 300, + resetAtMs: now + 30_000, + }, + secondary: { + usedPercent: 0, + windowMinutes: 10080, + resetAtMs: now + 5_000, + }, + }, + }); + + expect(result.availability).toBe("unavailable"); + expect( + result.reasons.some((reason) => + reason.includes("live probe returned 429"), + ), + ).toBe(true); + }); + + it("breaks delayed recommendation ties in favor of the current account", () => { + const now = 1_700_000_000_000; + const results = evaluateForecastAccounts([ + { + index: 5, + now, + isCurrent: false, + account: { + refreshToken: "x", + addedAt: now - 1_000, + lastUsed: now - 1_000, + coolingDownUntil: now + 45_000, + }, + }, + { + index: 3, + now, + isCurrent: true, + account: { + refreshToken: "y", + addedAt: now - 1_000, + lastUsed: now - 1_000, + coolingDownUntil: now + 45_000, + }, + }, + ]); + + const recommendation = recommendForecastAccount(results); + expect(recommendation.recommendedIndex).toBe(3); + }); + it("returns null recommendation when all candidates are disabled or hard-failed", () => { const now = 1_700_000_000_000; const results = evaluateForecastAccounts([ @@ -434,6 +579,8 @@ describe("forecast helpers", () => { const recommendation = recommendForecastAccount(results); expect(recommendation.recommendedIndex).toBeNull(); - expect(recommendation.reason).toContain("No healthy accounts are available"); + expect(recommendation.reason).toContain( + "No healthy accounts are available", + ); }); }); diff --git a/test/index.test.ts b/test/index.test.ts index 5fec198d..d6d95497 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -40,7 +40,7 @@ vi.mock("../lib/auth/auth.js", () => ({ }; }), redactOAuthUrlForLog: vi.fn((url: string) => url.replace(/state=[^&]+/, "state=%3Credacted%3E")), - REDIRECT_URI: "http://127.0.0.1:1455/auth/callback", + REDIRECT_URI: "http://localhost:1455/auth/callback", })); vi.mock("../lib/refresh-queue.js", () => ({ @@ -1093,24 +1093,126 @@ describe("OpenAIOAuthPlugin fetch handler", () => { const { sdk } = await setupPlugin(); const controller = new AbortController(); controller.abort(abortError); + await expect( + sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1" }), + signal: controller.signal, + }), + ).rejects.toMatchObject({ message: "aborted by user" }); + expect(recordFailureSpy).not.toHaveBeenCalled(); + expect(markCooldownSpy).not.toHaveBeenCalled(); + expect(refundSpy).toHaveBeenCalled(); + expect(capabilityFailureSpy).not.toHaveBeenCalled(); + }); + + it("skips fetch when local token bucket is depleted", async () => { + const { AccountManager } = await import("../lib/accounts.js"); + const consumeSpy = vi.spyOn(AccountManager.prototype, "consumeToken").mockReturnValue(false); + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ content: "should-not-be-returned" }), { status: 200 }), + ); + + const { sdk } = await setupPlugin(); const response = await sdk.fetch!("https://api.openai.com/v1/chat", { method: "POST", body: JSON.stringify({ model: "gpt-5.1" }), - signal: controller.signal, }); + expect(globalThis.fetch).not.toHaveBeenCalled(); expect(response.status).toBe(503); - expect(recordFailureSpy).not.toHaveBeenCalled(); - expect(markCooldownSpy).not.toHaveBeenCalled(); - expect(refundSpy).not.toHaveBeenCalled(); - expect(capabilityFailureSpy).not.toHaveBeenCalled(); + expect(await response.text()).toContain("server errors or auth issues"); + consumeSpy.mockRestore(); + }); + + it("continues to next account when local token bucket is depleted", async () => { + const { AccountManager } = await import("../lib/accounts.js"); + const accountOne = { + index: 0, + accountId: "acc-1", + email: "user1@example.com", + refreshToken: "refresh-1", + }; + const accountTwo = { + index: 1, + accountId: "acc-2", + email: "user2@example.com", + refreshToken: "refresh-2", + }; + const countSpy = vi + .spyOn(AccountManager.prototype, "getAccountCount") + .mockReturnValue(2); + const selectionSpy = vi + .spyOn(AccountManager.prototype, "getCurrentOrNextForFamilyHybrid") + .mockImplementationOnce(() => accountOne) + .mockImplementationOnce(() => accountTwo) + .mockImplementation(() => null); + const consumeSpy = vi + .spyOn(AccountManager.prototype, "consumeToken") + .mockReturnValueOnce(false) + .mockReturnValueOnce(true); + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ content: "from-second-account" }), { status: 200 }), + ); + + const { sdk } = await setupPlugin(); + const response = await sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1" }), + }); + + expect(response.status).toBe(200); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + expect(consumeSpy).toHaveBeenCalledTimes(2); + countSpy.mockRestore(); + selectionSpy.mockRestore(); + consumeSpy.mockRestore(); + }); + + it("treats timeout-triggered abort as network failure", async () => { + const { AccountManager } = await import("../lib/accounts.js"); + const configModule = await import("../lib/config.js"); + const recordFailureSpy = vi.spyOn(AccountManager.prototype, "recordFailure"); + const timeoutSpy = vi.spyOn(configModule, "getFetchTimeoutMs").mockReturnValueOnce(5); + globalThis.fetch = vi.fn((_: unknown, init?: { signal?: AbortSignal }) => { + return new Promise((_resolve, reject) => { + const signal = init?.signal; + if (!signal) { + reject(new Error("missing signal")); + return; + } + if (signal.aborted) { + reject(Object.assign(new Error("timeout"), { name: "AbortError" })); + return; + } + signal.addEventListener( + "abort", + () => reject(Object.assign(new Error("timeout"), { name: "AbortError" })), + { once: true }, + ); + }); + }); + + const { sdk } = await setupPlugin(); + const response = await sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1" }), + }); + + expect(response.status).toBe(503); + expect(recordFailureSpy).toHaveBeenCalled(); + recordFailureSpy.mockRestore(); + timeoutSpy.mockRestore(); }); - it("skips fetch when local token bucket is depleted", async () => { + it("uses numeric retry-after hints for server cooldown decisions", async () => { const { AccountManager } = await import("../lib/accounts.js"); - const consumeSpy = vi.spyOn(AccountManager.prototype, "consumeToken").mockReturnValue(false); + const cooldownSpy = vi.spyOn(AccountManager.prototype, "markAccountCoolingDown"); globalThis.fetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ content: "should-not-be-returned" }), { status: 200 }), + new Response("server error", { + status: 500, + headers: new Headers({ "retry-after": "5" }), + }), ); const { sdk } = await setupPlugin(); @@ -1119,10 +1221,110 @@ describe("OpenAIOAuthPlugin fetch handler", () => { body: JSON.stringify({ model: "gpt-5.1" }), }); - expect(globalThis.fetch).not.toHaveBeenCalled(); expect(response.status).toBe(503); - expect(await response.text()).toContain("server errors or auth issues"); - consumeSpy.mockRestore(); + expect(cooldownSpy).toHaveBeenCalled(); + expect(cooldownSpy.mock.calls[0]?.[1]).toBe(5000); + cooldownSpy.mockRestore(); + }); + + it("uses retry-after-ms hints for server cooldown decisions", async () => { + const { AccountManager } = await import("../lib/accounts.js"); + const cooldownSpy = vi.spyOn(AccountManager.prototype, "markAccountCoolingDown"); + globalThis.fetch = vi.fn().mockResolvedValue( + new Response("server error", { + status: 500, + headers: new Headers({ "retry-after-ms": "4500" }), + }), + ); + + const { sdk } = await setupPlugin(); + const response = await sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1" }), + }); + + expect(response.status).toBe(503); + expect(cooldownSpy).toHaveBeenCalled(); + expect(cooldownSpy.mock.calls[0]?.[1]).toBe(4500); + cooldownSpy.mockRestore(); + }); + + it("parses x-ratelimit-reset seconds hints for server cooldown decisions", async () => { + const baseNow = 1_700_000_000_000; + const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(baseNow); + const resetAtSeconds = Math.floor((baseNow + 7_000) / 1000); + const { AccountManager } = await import("../lib/accounts.js"); + const cooldownSpy = vi.spyOn(AccountManager.prototype, "markAccountCoolingDown"); + globalThis.fetch = vi.fn().mockResolvedValue( + new Response("server error", { + status: 500, + headers: new Headers({ "x-ratelimit-reset": String(resetAtSeconds) }), + }), + ); + + const { sdk } = await setupPlugin(); + const response = await sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1" }), + }); + + expect(response.status).toBe(503); + expect(cooldownSpy).toHaveBeenCalled(); + expect(cooldownSpy.mock.calls[0]?.[1]).toBe(7000); + cooldownSpy.mockRestore(); + dateNowSpy.mockRestore(); + }); + + it("parses x-ratelimit-reset millisecond hints for server cooldown decisions", async () => { + const baseNow = 1_700_000_000_000; + const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(baseNow); + const resetAtMs = baseNow + 12_000; + const { AccountManager } = await import("../lib/accounts.js"); + const cooldownSpy = vi.spyOn(AccountManager.prototype, "markAccountCoolingDown"); + globalThis.fetch = vi.fn().mockResolvedValue( + new Response("server error", { + status: 500, + headers: new Headers({ "x-ratelimit-reset": String(resetAtMs) }), + }), + ); + + const { sdk } = await setupPlugin(); + const response = await sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1" }), + }); + + expect(response.status).toBe(503); + expect(cooldownSpy).toHaveBeenCalled(); + expect(cooldownSpy.mock.calls[0]?.[1]).toBe(12000); + cooldownSpy.mockRestore(); + dateNowSpy.mockRestore(); + }); + + it("parses HTTP-date retry-after hints for server cooldown decisions", async () => { + const baseNow = 1_700_000_000_000; + const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(baseNow); + const retryAt = new Date(baseNow + 9_000).toUTCString(); + const { AccountManager } = await import("../lib/accounts.js"); + const cooldownSpy = vi.spyOn(AccountManager.prototype, "markAccountCoolingDown"); + globalThis.fetch = vi.fn().mockResolvedValue( + new Response("server error", { + status: 500, + headers: new Headers({ "retry-after": retryAt }), + }), + ); + + const { sdk } = await setupPlugin(); + const response = await sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1" }), + }); + + expect(response.status).toBe(503); + expect(cooldownSpy).toHaveBeenCalled(); + expect(cooldownSpy.mock.calls[0]?.[1]).toBe(9000); + cooldownSpy.mockRestore(); + dateNowSpy.mockRestore(); }); it("falls back from gpt-5.3-codex to gpt-5.2-codex when unsupported fallback is enabled", async () => { diff --git a/test/install-codex-auth-retry.test.ts b/test/install-codex-auth-retry.test.ts new file mode 100644 index 00000000..7b9f44cd --- /dev/null +++ b/test/install-codex-auth-retry.test.ts @@ -0,0 +1,99 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { renameWithRetry } from "../scripts/install-codex-auth-utils.js"; + +function makeRenameError(code: string): NodeJS.ErrnoException { + const error = new Error(`rename failed: ${code}`) as NodeJS.ErrnoException; + error.code = code; + return error; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("install-codex-auth renameWithRetry", () => { + it("retries EPERM/EBUSY/EACCES with exponential backoff, jitter, and logs", async () => { + const renameMock = vi + .fn() + .mockRejectedValueOnce(makeRenameError("EPERM")) + .mockRejectedValueOnce(makeRenameError("EBUSY")) + .mockRejectedValueOnce(makeRenameError("EACCES")) + .mockResolvedValue(undefined); + const random = vi.fn().mockReturnValue(0.5); + const delays: number[] = []; + const logs: string[] = []; + await renameWithRetry("config.json.tmp", "config.json", { + rename: renameMock, + random, + sleep: async (delayMs) => { + delays.push(delayMs); + }, + log: (message) => { + logs.push(message); + }, + }); + + expect(renameMock).toHaveBeenCalledTimes(4); + expect(delays).toEqual([35, 60, 110]); + expect(logs.length).toBe(3); + expect(logs[0]).toContain("code=EPERM"); + expect(logs[1]).toContain("code=EBUSY"); + expect(logs[2]).toContain("code=EACCES"); + expect(random).toHaveBeenCalledTimes(3); + }); + + it.each(["EBUSY", "ENOTEMPTY"] as const)( + "throws after exhausting retries for retryable %s errors", + async (code) => { + const renameMock = vi.fn().mockRejectedValue(makeRenameError(code)); + const random = vi.fn().mockReturnValue(0.5); + const delays: number[] = []; + const logs: string[] = []; + await expect( + renameWithRetry("config.json.tmp", "config.json", { + rename: renameMock, + maxRetries: 3, + random, + sleep: async (delayMs) => { + delays.push(delayMs); + }, + log: (message) => { + logs.push(message); + }, + }), + ).rejects.toMatchObject({ code }); + expect(renameMock).toHaveBeenCalledTimes(3); + expect(delays).toEqual([35, 60]); + expect(random).toHaveBeenCalledTimes(2); + expect(logs.length).toBe(2); + expect(logs[0]).toContain(`code=${code}`); + expect(logs[1]).toContain(`code=${code}`); + }, + ); + + it("throws when maxRetries is less than 1", async () => { + const renameMock = vi.fn(); + await expect( + renameWithRetry("config.json.tmp", "config.json", { + rename: renameMock, + maxRetries: 0, + }), + ).rejects.toThrow("maxRetries must be an integer >= 1"); + expect(renameMock).not.toHaveBeenCalled(); + }); + + it("throws immediately for non-retryable rename errors", async () => { + const renameMock = vi.fn().mockRejectedValue(makeRenameError("EINVAL")); + const delays: number[] = []; + await expect( + renameWithRetry("config.json.tmp", "config.json", { + rename: renameMock, + sleep: async (delayMs) => { + delays.push(delayMs); + }, + }), + ).rejects.toMatchObject({ code: "EINVAL" }); + expect(renameMock).toHaveBeenCalledTimes(1); + expect(delays).toEqual([]); + }); +}); diff --git a/test/install-codex-auth.test.ts b/test/install-codex-auth.test.ts index 2bc5639c..fdd2cf0b 100644 --- a/test/install-codex-auth.test.ts +++ b/test/install-codex-auth.test.ts @@ -1,12 +1,15 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { mkdtempSync, readFileSync, rmSync, existsSync, writeFileSync, readdirSync, mkdirSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { spawnSync, execFile } from "node:child_process"; import { promisify } from "node:util"; import { + FILE_RETRY_BASE_DELAY_MS, + FILE_RETRY_MAX_ATTEMPTS, normalizePluginList, resolveInstallPaths, + withFileOperationRetry, } from "../scripts/install-codex-auth-utils.js"; const scriptPath = "scripts/install-codex-auth.js"; @@ -14,6 +17,8 @@ const tempRoots: string[] = []; const execFileAsync = promisify(execFile); afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); while (tempRoots.length > 0) { const root = tempRoots.pop(); if (root) { @@ -22,6 +27,12 @@ afterEach(() => { } }); +function retryableError(code: string): Error & { code: string } { + const error = new Error(`transient ${code}`) as Error & { code: string }; + error.code = code; + return error; +} + describe("install-codex-auth script", () => { it("uses lowercase config template filenames", () => { const content = readFileSync(scriptPath, "utf8"); @@ -131,4 +142,51 @@ describe("install-codex-auth script", () => { const configPath = path.join(appData, "Codex", "Codex.json"); expect(existsSync(configPath)).toBe(false); }); + + it("retries transient file-operation errors and eventually succeeds", async () => { + vi.useFakeTimers(); + const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0); + const operation = vi.fn<() => Promise>() + .mockRejectedValueOnce(retryableError("EBUSY")) + .mockRejectedValueOnce(retryableError("EPERM")) + .mockResolvedValue("ok"); + + const pending = withFileOperationRetry(operation); + await Promise.resolve(); + expect(operation).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(FILE_RETRY_BASE_DELAY_MS); + expect(operation).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(FILE_RETRY_BASE_DELAY_MS * 2); + await expect(pending).resolves.toBe("ok"); + expect(operation).toHaveBeenCalledTimes(3); + + randomSpy.mockRestore(); + }); + + it("throws after max retry attempts for persistent transient errors", async () => { + vi.useFakeTimers(); + const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0); + const operation = vi.fn<() => Promise>().mockRejectedValue(retryableError("EAGAIN")); + + const pending = withFileOperationRetry(operation); + pending.catch(() => undefined); + for (let attempt = 1; attempt < FILE_RETRY_MAX_ATTEMPTS; attempt += 1) { + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(FILE_RETRY_BASE_DELAY_MS * (2 ** (attempt - 1))); + } + + await expect(pending).rejects.toMatchObject({ code: "EAGAIN" }); + expect(operation).toHaveBeenCalledTimes(FILE_RETRY_MAX_ATTEMPTS); + + randomSpy.mockRestore(); + }); + + it("throws immediately for non-retryable file-operation errors", async () => { + const operation = vi.fn<() => Promise>().mockRejectedValue(retryableError("ENOENT")); + + await expect(withFileOperationRetry(operation)).rejects.toMatchObject({ code: "ENOENT" }); + expect(operation).toHaveBeenCalledTimes(1); + }); }); diff --git a/test/live-account-sync-edge.test.ts b/test/live-account-sync-edge.test.ts new file mode 100644 index 00000000..e606fe25 --- /dev/null +++ b/test/live-account-sync-edge.test.ts @@ -0,0 +1,259 @@ +import { promises as fs } from "node:fs"; +import { join, basename } from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const RETRYABLE_REMOVE_CODES = new Set(["EBUSY", "EPERM", "ENOTEMPTY"]); + +async function removeWithRetry( + targetPath: string, + options: { recursive?: boolean; force?: boolean }, +): Promise { + for (let attempt = 0; attempt < 6; attempt += 1) { + try { + await fs.rm(targetPath, options); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") return; + if (!code || !RETRYABLE_REMOVE_CODES.has(code) || attempt === 5) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 25 * 2 ** attempt)); + } + } +} + +async function importLiveAccountSyncWithFsMock(options?: { + watch?: ( + path: string, + options: { persistent: boolean }, + listener: (eventType: string, filename: string | Buffer | null) => void, + ) => { close: () => void }; + stat?: typeof import("node:fs").promises.stat; +}) { + vi.resetModules(); + vi.doMock("node:fs", async () => { + const actual = await vi.importActual("node:fs"); + return { + ...actual, + watch: options?.watch ?? actual.watch, + promises: { + ...actual.promises, + stat: options?.stat ?? actual.promises.stat, + }, + }; + }); + + return import("../lib/live-account-sync.js"); +} + +describe("live-account-sync edge cases", () => { + let workDir = ""; + let storagePath = ""; + + beforeEach(async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-01T10:00:00.000Z")); + workDir = await fs.mkdtemp(join(tmpdir(), "codex-live-sync-edge-")); + storagePath = join(workDir, "openai-codex-accounts.json"); + await fs.writeFile( + storagePath, + JSON.stringify({ version: 3, activeIndex: 0, accounts: [] }), + "utf8", + ); + }); + + afterEach(async () => { + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.resetModules(); + vi.doUnmock("node:fs"); + if (workDir) { + await removeWithRetry(workDir, { recursive: true, force: true }); + } + }); + + it("clamps intervals and short-circuits empty-path / pre-run calls", async () => { + const { LiveAccountSync } = await importLiveAccountSyncWithFsMock(); + const reload = vi.fn(async () => undefined); + const sync = new LiveAccountSync(reload, { + debounceMs: 1.1, + pollIntervalMs: 2.2, + }); + + expect(Reflect.get(sync, "debounceMs")).toBe(50); + expect(Reflect.get(sync, "pollIntervalMs")).toBe(500); + + await sync.syncToPath(""); + expect(sync.getSnapshot().running).toBe(false); + expect(sync.getSnapshot().path).toBeNull(); + + const pollOnce = Reflect.get(sync, "pollOnce") as () => Promise; + const runReload = Reflect.get(sync, "runReload") as ( + reason: "watch" | "poll", + ) => Promise; + await Reflect.apply( + pollOnce as (...args: unknown[]) => unknown, + sync as object, + [], + ); + await Reflect.apply( + runReload as (...args: unknown[]) => unknown, + sync as object, + ["watch"], + ); + + expect(reload).not.toHaveBeenCalled(); + }); + + it("handles non-finite mtime and watch setup failures", async () => { + const watchMock = vi.fn(() => { + throw "watch-failed"; + }); + const statMock = vi.fn(async (...args: Parameters) => { + if (String(args[0]) === storagePath) { + return { mtimeMs: Number.NaN } as Awaited>; + } + return fs.stat(...args); + }); + + const { LiveAccountSync } = await importLiveAccountSyncWithFsMock({ + watch: watchMock, + stat: statMock, + }); + const sync = new LiveAccountSync(async () => undefined, { + debounceMs: 50, + pollIntervalMs: 500, + }); + + await sync.syncToPath(storagePath); + + const snapshot = sync.getSnapshot(); + expect(snapshot.running).toBe(true); + expect(snapshot.lastKnownMtimeMs).toBeNull(); + expect(snapshot.errorCount).toBeGreaterThan(0); + expect(watchMock).toHaveBeenCalled(); + sync.stop(); + }); + + it("treats retryable stat read errors as missing mtime", async () => { + const watchMock = vi.fn((_dir, _options, _listener) => ({ + close: vi.fn(), + })); + const statMock = vi.fn(async (...args: Parameters) => { + if (String(args[0]) === storagePath) { + const error = new Error("busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return fs.stat(...args); + }); + + const { LiveAccountSync } = await importLiveAccountSyncWithFsMock({ + watch: watchMock, + stat: statMock, + }); + const sync = new LiveAccountSync(async () => undefined, { + debounceMs: 50, + pollIntervalMs: 500, + }); + + await sync.syncToPath(storagePath); + expect(sync.getSnapshot().lastKnownMtimeMs).toBeNull(); + sync.stop(); + }); + + it("handles watch callbacks for null and buffer filenames and ignores unrelated names", async () => { + let callback: + | ((eventType: string, filename: string | Buffer | null) => void) + | undefined; + const closeMock = vi.fn(); + const watchMock = vi.fn( + ( + _dir: string, + _options: { persistent: boolean }, + listener: (eventType: string, filename: string | Buffer | null) => void, + ) => { + callback = listener; + return { close: closeMock }; + }, + ); + const statMock = vi.fn(async (...args: Parameters) => { + if (String(args[0]) === storagePath) { + return { mtimeMs: 1_000 } as Awaited>; + } + return fs.stat(...args); + }); + + const { LiveAccountSync } = await importLiveAccountSyncWithFsMock({ + watch: watchMock, + stat: statMock, + }); + const reload = vi.fn(async () => undefined); + const sync = new LiveAccountSync(reload, { + debounceMs: 50, + pollIntervalMs: 500, + }); + + await sync.syncToPath(storagePath); + await sync.syncToPath(storagePath); + expect(watchMock).toHaveBeenCalledTimes(1); + + callback?.("change", null); + await vi.advanceTimersByTimeAsync(80); + expect(reload).toHaveBeenCalledTimes(1); + + const name = basename(storagePath); + callback?.("change", Buffer.from(name, "utf8")); + await vi.advanceTimersByTimeAsync(80); + expect(reload).toHaveBeenCalledTimes(2); + + callback?.("change", Buffer.from(`${name}.tmp`, "utf8")); + await vi.advanceTimersByTimeAsync(80); + expect(reload).toHaveBeenCalledTimes(3); + + callback?.("change", Buffer.from("unrelated.json", "utf8")); + await vi.advanceTimersByTimeAsync(80); + expect(reload).toHaveBeenCalledTimes(3); + + sync.stop(); + expect(closeMock).toHaveBeenCalledTimes(1); + }); + + it("counts poll-triggered reload failures with non-Error throws", async () => { + const watchMock = vi.fn((_dir, _options, _listener) => ({ + close: vi.fn(), + })); + let statReads = 0; + const statMock = vi.fn(async (...args: Parameters) => { + if (String(args[0]) === storagePath) { + statReads += 1; + if (statReads === 1) { + return { mtimeMs: 1_000 } as Awaited>; + } + return { mtimeMs: 2_000 } as Awaited>; + } + return fs.stat(...args); + }); + const { LiveAccountSync } = await importLiveAccountSyncWithFsMock({ + watch: watchMock, + stat: statMock, + }); + const reload = vi.fn(async () => { + throw "reload-string-failure"; + }); + const sync = new LiveAccountSync(reload, { + debounceMs: 50, + pollIntervalMs: 500, + }); + + await sync.syncToPath(storagePath); + await vi.advanceTimersByTimeAsync(800); + + const snapshot = sync.getSnapshot(); + expect(snapshot.errorCount).toBeGreaterThan(0); + expect(snapshot.reloadCount).toBe(0); + sync.stop(); + }); +}); diff --git a/test/lockfile-version-floor.test.ts b/test/lockfile-version-floor.test.ts new file mode 100644 index 00000000..37262fff --- /dev/null +++ b/test/lockfile-version-floor.test.ts @@ -0,0 +1,107 @@ +import { readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const projectRoot = resolve(process.cwd()); +const MIN_HONO_FLOOR = "4.12.2"; +const MIN_ROLLUP_FLOOR = "4.59.0"; + +function readJson(filePath: string): unknown { + return JSON.parse(readFileSync(join(projectRoot, filePath), "utf-8")); +} + +function extractSemverFloor(range: string): string { + const match = range.match(/(\d+)\.(\d+)\.(\d+)/); + if (!match) { + throw new Error(`Unable to extract semver floor from range "${range}"`); + } + return `${match[1]}.${match[2]}.${match[3]}`; +} + +function parseSemver(version: string): [number, number, number] { + const match = version.match(/^v?(\d+)\.(\d+)\.(\d+)$/); + if (!match) { + throw new Error(`Invalid semver "${version}"`); + } + return [ + Number.parseInt(match[1], 10), + Number.parseInt(match[2], 10), + Number.parseInt(match[3], 10), + ]; +} + +function compareSemver(left: string, right: string): number { + const leftParts = parseSemver(left); + const rightParts = parseSemver(right); + for (let index = 0; index < 3; index += 1) { + const delta = leftParts[index] - rightParts[index]; + if (delta !== 0) { + return delta; + } + } + return 0; +} + +function isObjectRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function readStringField(record: Record, key: string): string { + const value = record[key]; + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`Expected non-empty string at key "${key}"`); + } + return value; +} + +describe("lockfile version floors", () => { + it("keeps configured floors at or above hardened minimums", () => { + const packageJson = readJson("package.json"); + expect(isObjectRecord(packageJson)).toBe(true); + + const dependencies = isObjectRecord(packageJson) && isObjectRecord(packageJson.dependencies) + ? packageJson.dependencies + : {}; + const overrides = isObjectRecord(packageJson) && isObjectRecord(packageJson.overrides) + ? packageJson.overrides + : {}; + + const honoFloor = extractSemverFloor(readStringField(dependencies, "hono")); + const rollupFloor = extractSemverFloor(readStringField(overrides, "rollup")); + + expect(compareSemver(honoFloor, MIN_HONO_FLOOR)).toBeGreaterThanOrEqual(0); + expect(compareSemver(rollupFloor, MIN_ROLLUP_FLOOR)).toBeGreaterThanOrEqual(0); + }); + + it("keeps lockfile resolved versions at or above declared floors", () => { + const packageJson = readJson("package.json"); + const lockfile = readJson("package-lock.json"); + expect(isObjectRecord(packageJson)).toBe(true); + expect(isObjectRecord(lockfile)).toBe(true); + + const dependencies = isObjectRecord(packageJson) && isObjectRecord(packageJson.dependencies) + ? packageJson.dependencies + : {}; + const overrides = isObjectRecord(packageJson) && isObjectRecord(packageJson.overrides) + ? packageJson.overrides + : {}; + const packages = + isObjectRecord(lockfile) && isObjectRecord(lockfile.packages) + ? lockfile.packages + : {}; + + const resolvedHonoRecord = isObjectRecord(packages["node_modules/hono"]) ? packages["node_modules/hono"] : null; + const resolvedRollupRecord = isObjectRecord(packages["node_modules/rollup"]) ? packages["node_modules/rollup"] : null; + + expect(resolvedHonoRecord).not.toBeNull(); + expect(resolvedRollupRecord).not.toBeNull(); + + const declaredHonoFloor = extractSemverFloor(readStringField(dependencies, "hono")); + const declaredRollupFloor = extractSemverFloor(readStringField(overrides, "rollup")); + const resolvedHonoVersion = readStringField(resolvedHonoRecord ?? {}, "version"); + const resolvedRollupVersion = readStringField(resolvedRollupRecord ?? {}, "version"); + + expect(compareSemver(resolvedHonoVersion, declaredHonoFloor)).toBeGreaterThanOrEqual(0); + expect(compareSemver(resolvedRollupVersion, declaredRollupFloor)).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/test/oauth-server.integration.test.ts b/test/oauth-server.integration.test.ts index af4c7807..9b135bc7 100644 --- a/test/oauth-server.integration.test.ts +++ b/test/oauth-server.integration.test.ts @@ -25,7 +25,7 @@ describe("OAuth Server Integration", () => { // Simulate OAuth callback const testCode = "auth-code-67890"; - const callbackUrl = `http://127.0.0.1:1455/auth/callback?code=${testCode}&state=${testState}`; + const callbackUrl = `http://localhost:1455/auth/callback?code=${testCode}&state=${testState}`; const response = await fetch(callbackUrl); expect(response.status).toBe(200); @@ -42,7 +42,7 @@ describe("OAuth Server Integration", () => { expect(serverInfo.ready).toBe(true); - const callbackUrl = `http://127.0.0.1:1455/auth/callback?code=test&state=wrong-state`; + const callbackUrl = `http://localhost:1455/auth/callback?code=test&state=wrong-state`; const response = await fetch(callbackUrl); expect(response.status).toBe(400); @@ -56,7 +56,7 @@ describe("OAuth Server Integration", () => { expect(serverInfo.ready).toBe(true); - const callbackUrl = `http://127.0.0.1:1455/auth/callback?state=${testState}`; + const callbackUrl = `http://localhost:1455/auth/callback?state=${testState}`; const response = await fetch(callbackUrl); expect(response.status).toBe(400); @@ -70,7 +70,7 @@ describe("OAuth Server Integration", () => { expect(serverInfo.ready).toBe(true); - const response = await fetch("http://127.0.0.1:1455/other-path"); + const response = await fetch("http://localhost:1455/other-path"); expect(response.status).toBe(404); }); @@ -85,7 +85,7 @@ describe("OAuth Server Integration", () => { // Subsequent requests should fail (server closed) await expect( - fetch("http://127.0.0.1:1455/auth/callback?code=test&state=test") + fetch("http://localhost:1455/auth/callback?code=test&state=test") ).rejects.toThrow(); serverInfo = null; // Prevent double-close in afterEach diff --git a/test/parallel-probe.test.ts b/test/parallel-probe.test.ts index 15b09df9..bc4cc3b0 100644 --- a/test/parallel-probe.test.ts +++ b/test/parallel-probe.test.ts @@ -260,5 +260,59 @@ describe("parallel-probe", () => { expect(candidates).toHaveLength(2); expect(candidates[0].index).toBe(1); }); + + it("supports named-parameter options form", () => { + const accounts = [ + createMockAccount(0, { lastUsed: Date.now() }), + createMockAccount(1, { lastUsed: Date.now() - 1000 * 60 * 60 * 2 }), + ]; + + const mockManager = { + getAccountsSnapshot: vi.fn().mockReturnValue(accounts), + }; + + const positional = getTopCandidates( + mockManager as unknown as Parameters[0], + "codex", + null, + 2, + ); + const named = getTopCandidates({ + accountManager: mockManager as unknown as Parameters[0], + modelFamily: "codex", + model: null, + maxCandidates: 2, + }); + + expect(named).toEqual(positional); + }); + + it("throws clear TypeError when accountManager is missing required shape", () => { + expect(() => + getTopCandidates({ + accountManager: {} as unknown as Parameters[0], + modelFamily: "codex", + model: null, + maxCandidates: 2, + }), + ).toThrowError("getTopCandidates requires accountManager"); + }); + + it("throws clear TypeError for invalid maxCandidates values", () => { + const mockManager = { + getAccountsSnapshot: vi.fn().mockReturnValue([createMockAccount(0)]), + }; + const invalidValues = [0, -1, Number.NaN, Number.POSITIVE_INFINITY, 1.5]; + for (const value of invalidValues) { + expect(() => + getTopCandidates({ + accountManager: mockManager as unknown as Parameters[0], + modelFamily: "codex", + model: null, + maxCandidates: value, + }), + ).toThrowError("getTopCandidates requires maxCandidates to be a positive integer"); + } + }); }); }); diff --git a/test/preemptive-quota-scheduler.test.ts b/test/preemptive-quota-scheduler.test.ts index 9bbbce0e..1d58db8c 100644 --- a/test/preemptive-quota-scheduler.test.ts +++ b/test/preemptive-quota-scheduler.test.ts @@ -27,6 +27,39 @@ describe("preemptive quota scheduler", () => { expect(snapshot?.primary.resetAtMs).toBe(125_000); }); + it("returns null when quota headers are present but invalid", () => { + const headers = new Headers({ + "x-codex-primary-used-percent": "not-a-number", + "x-codex-primary-reset-after-seconds": "oops", + "x-codex-primary-reset-at": "not-a-date", + "x-codex-secondary-reset-at": "still-not-a-date", + }); + expect(readQuotaSchedulerSnapshot(headers, 200, 5_000)).toBeNull(); + }); + + it("parses reset-at as epoch seconds, milliseconds, and HTTP date", () => { + const secondsSnapshot = readQuotaSchedulerSnapshot( + new Headers({ + "x-codex-primary-reset-at": "1700000000", + "x-codex-secondary-reset-at": "1700000000000", + }), + 200, + 0, + ); + expect(secondsSnapshot?.primary.resetAtMs).toBe(1_700_000_000_000); + expect(secondsSnapshot?.secondary.resetAtMs).toBe(1_700_000_000_000); + + const dateText = "Tue, 14 Nov 2023 22:13:20 GMT"; + const dateSnapshot = readQuotaSchedulerSnapshot( + new Headers({ + "x-codex-primary-reset-at": dateText, + }), + 200, + 0, + ); + expect(dateSnapshot?.primary.resetAtMs).toBe(Date.parse(dateText)); + }); + it("defers requests for known 429 window", () => { const scheduler = new PreemptiveQuotaScheduler(); scheduler.markRateLimited("acc:model", 30_000, 1_000); @@ -53,7 +86,9 @@ describe("preemptive quota scheduler", () => { }); it("defers when usage is near exhaustion and reset is pending", () => { - const scheduler = new PreemptiveQuotaScheduler({ usedPercentThreshold: 95 }); + const scheduler = new PreemptiveQuotaScheduler({ + usedPercentThreshold: 95, + }); scheduler.update("acc:model", { status: 200, primary: { @@ -118,4 +153,51 @@ describe("preemptive quota scheduler", () => { scheduler.configure({ enabled: true }); expect(scheduler.getDeferral("acc:model", 2_000).defer).toBe(true); }); + + it("ignores empty keys for update/markRateLimited and falls back when updatedAt is falsy", () => { + const now = Date.now(); + const scheduler = new PreemptiveQuotaScheduler(); + + scheduler.update("", { + status: 200, + primary: { usedPercent: 99, resetAtMs: now + 60_000 }, + secondary: {}, + updatedAt: now, + }); + scheduler.markRateLimited("", 30_000, now); + expect(scheduler.getDeferral("", now + 1_000)).toEqual({ + defer: false, + waitMs: 0, + }); + + scheduler.update("acc:model", { + status: 429, + primary: { usedPercent: 100, resetAtMs: now + 45_000 }, + secondary: {}, + updatedAt: 0, + }); + const decision = scheduler.getDeferral("acc:model", now + 5_000); + expect(decision.defer).toBe(true); + expect(decision.reason).toBe("rate-limit"); + }); + + it("prunes snapshots using the latest reset from primary or secondary windows", () => { + const scheduler = new PreemptiveQuotaScheduler(); + scheduler.update("keep", { + status: 200, + primary: { resetAtMs: 1_000 }, + secondary: { resetAtMs: 7_000 }, + updatedAt: 0, + }); + scheduler.update("drop", { + status: 200, + primary: { resetAtMs: 1_000 }, + secondary: { resetAtMs: 1_500 }, + updatedAt: 0, + }); + + const removed = scheduler.prune(2_000); + expect(removed).toBe(1); + expect(scheduler.prune(8_000)).toBe(1); + }); }); diff --git a/test/public-api-contract.test.ts b/test/public-api-contract.test.ts new file mode 100644 index 00000000..307093f3 --- /dev/null +++ b/test/public-api-contract.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it, vi } from "vitest"; +import { + HealthScoreTracker, + TokenBucketTracker, + exponentialBackoff, + selectHybridAccount, +} from "../lib/rotation.js"; +import { getTopCandidates } from "../lib/parallel-probe.js"; +import { createCodexHeaders } from "../lib/request/fetch-helpers.js"; +import { + clearRateLimitBackoffState, + getRateLimitBackoffWithReason, +} from "../lib/request/rate-limit-backoff.js"; +import { transformRequestBody } from "../lib/request/request-transformer.js"; +import type { RequestBody } from "../lib/types.js"; + +describe("public api contract", () => { + it("keeps root plugin exports aligned", async () => { + const root = await import("../index.js"); + expect(typeof root.OpenAIOAuthPlugin).toBe("function"); + expect(root.OpenAIAuthPlugin).toBe(root.OpenAIOAuthPlugin); + expect(root.default).toBe(root.OpenAIOAuthPlugin); + }); + + it("keeps compatibility exports for module helpers", async () => { + const rotation = await import("../lib/rotation.js"); + const parallelProbe = await import("../lib/parallel-probe.js"); + const fetchHelpers = await import("../lib/request/fetch-helpers.js"); + const rateLimitBackoff = await import("../lib/request/rate-limit-backoff.js"); + const requestTransformer = await import("../lib/request/request-transformer.js"); + const required: ReadonlyArray< + readonly [string, Record] + > = [ + ["selectHybridAccount", rotation], + ["exponentialBackoff", rotation], + ["getTopCandidates", parallelProbe], + ["createCodexHeaders", fetchHelpers], + ["getRateLimitBackoffWithReason", rateLimitBackoff], + ["transformRequestBody", requestTransformer], + ]; + for (const [name, mod] of required) { + expect(name in mod, `missing export: ${name}`).toBe(true); + expect(typeof mod[name], `${name} should be a function`).toBe("function"); + } + }); + + it("keeps positional and options-object overload behavior aligned", async () => { + const healthTracker = new HealthScoreTracker(); + const tokenTracker = new TokenBucketTracker(); + const accounts = [{ index: 0, isAvailable: true, lastUsed: 1_709_280_000_000 }]; + + const selectedPositional = selectHybridAccount(accounts, healthTracker, tokenTracker); + const selectedNamed = selectHybridAccount({ accounts, healthTracker, tokenTracker }); + expect(selectedNamed?.index).toBe(selectedPositional?.index); + + const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.5); + try { + const backoffPositional = exponentialBackoff(3, 1000, 60000, 0); + const backoffNamed = exponentialBackoff({ attempt: 3, baseMs: 1000, maxMs: 60000, jitterFactor: 0 }); + expect(backoffNamed).toBe(backoffPositional); + } finally { + randomSpy.mockRestore(); + } + + const snapshotNow = Date.now(); + const managerAccounts = [ + { + index: 0, + refreshToken: "token-0", + lastUsed: snapshotNow, + addedAt: snapshotNow, + rateLimitResetTimes: {}, + }, + ]; + const manager = { + getAccountsSnapshot: () => managerAccounts, + }; + const topPositional = getTopCandidates( + manager as unknown as Parameters[0], + "codex", + null, + 1, + ); + const topNamed = getTopCandidates({ + accountManager: manager as unknown as Parameters[0], + modelFamily: "codex", + model: null, + maxCandidates: 1, + }); + expect(topNamed).toEqual(topPositional); + + const headersPositional = createCodexHeaders(undefined, "acct", "token", { + model: "gpt-5", + promptCacheKey: "session-compat", + }); + const headersNamed = createCodexHeaders({ + init: undefined, + accountId: "acct", + accessToken: "token", + opts: { model: "gpt-5", promptCacheKey: "session-compat" }, + }); + expect(headersNamed.get("Authorization")).toBe(headersPositional.get("Authorization")); + expect(headersNamed.get("conversation_id")).toBe(headersPositional.get("conversation_id")); + expect(headersNamed.get("session_id")).toBe(headersPositional.get("session_id")); + + const ratePositional = getRateLimitBackoffWithReason(1, "compat", 1000, "tokens"); + clearRateLimitBackoffState(); + const rateNamed = getRateLimitBackoffWithReason({ + accountIndex: 1, + quotaKey: "compat", + serverRetryAfterMs: 1000, + reason: "tokens", + }); + expect(rateNamed).toEqual(ratePositional); + + const baseBody: RequestBody = { + model: "gpt-5-codex", + input: [{ type: "message", role: "user", content: "hi" }], + }; + const transformedPositional = await transformRequestBody( + JSON.parse(JSON.stringify(baseBody)) as RequestBody, + "codex", + ); + const transformedNamed = await transformRequestBody({ + body: JSON.parse(JSON.stringify(baseBody)) as RequestBody, + codexInstructions: "codex", + }); + expect(transformedNamed).toEqual(transformedPositional); + }); +}); diff --git a/test/quota-cache.test.ts b/test/quota-cache.test.ts index fdbabe04..54b5ffb6 100644 --- a/test/quota-cache.test.ts +++ b/test/quota-cache.test.ts @@ -4,228 +4,347 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; describe("quota cache", () => { - let tempDir: string; - let originalDir: string | undefined; - - beforeEach(async () => { - originalDir = process.env.CODEX_MULTI_AUTH_DIR; - tempDir = await fs.mkdtemp(join(tmpdir(), "codex-multi-auth-quota-")); - process.env.CODEX_MULTI_AUTH_DIR = tempDir; - vi.resetModules(); - }); - - afterEach(async () => { - if (originalDir === undefined) { - delete process.env.CODEX_MULTI_AUTH_DIR; - } else { - process.env.CODEX_MULTI_AUTH_DIR = originalDir; - } - await fs.rm(tempDir, { recursive: true, force: true }); - }); - - it("returns empty cache by default", async () => { - const { loadQuotaCache } = await import("../lib/quota-cache.js"); - const data = await loadQuotaCache(); - expect(data).toEqual({ byAccountId: {}, byEmail: {} }); - }); - - it("saves and reloads quota entries", async () => { - const { loadQuotaCache, saveQuotaCache, getQuotaCachePath } = await import( - "../lib/quota-cache.js" - ); - - await saveQuotaCache({ - byAccountId: { - acc_1: { - updatedAt: Date.now(), - status: 200, - model: "gpt-5-codex", - planType: "plus", - primary: { usedPercent: 40, windowMinutes: 300 }, - secondary: { usedPercent: 20, windowMinutes: 10080 }, - }, - }, - byEmail: {}, - }); - - const loaded = await loadQuotaCache(); - expect(loaded.byAccountId.acc_1?.primary.usedPercent).toBe(40); - - const fileContent = await fs.readFile(getQuotaCachePath(), "utf8"); - expect(fileContent).toContain("\"version\": 1"); - }); - - it("ignores cache files with unsupported version", async () => { - const { loadQuotaCache, getQuotaCachePath } = await import("../lib/quota-cache.js"); - await fs.writeFile( - getQuotaCachePath(), - JSON.stringify({ - version: 2, - byAccountId: { - acc_1: { - updatedAt: Date.now(), - status: 200, - model: "gpt-5-codex", - primary: { usedPercent: 10 }, - secondary: { usedPercent: 5 }, - }, - }, - byEmail: {}, - }), - "utf8", - ); - - const loaded = await loadQuotaCache(); - expect(loaded).toEqual({ byAccountId: {}, byEmail: {} }); - }); - - it("retries transient EBUSY while loading cache", async () => { - const { loadQuotaCache, getQuotaCachePath } = await import("../lib/quota-cache.js"); - await fs.writeFile( - getQuotaCachePath(), - JSON.stringify({ - version: 1, - byAccountId: { - acc_1: { - updatedAt: Date.now(), - status: 200, - model: "gpt-5-codex", - primary: { usedPercent: 10 }, - secondary: { usedPercent: 5 }, - }, - }, - byEmail: {}, - }), - "utf8", - ); - - const realRead = fs.readFile.bind(fs); - let attempts = 0; - const readSpy = vi.spyOn(fs, "readFile"); - readSpy.mockImplementation(async (...args) => { - if (String(args[0]) === getQuotaCachePath()) { - attempts += 1; - if (attempts === 1) { - const error = new Error("busy") as NodeJS.ErrnoException; - error.code = "EBUSY"; - throw error; - } - } - return realRead(...args); - }); - - try { - const loaded = await loadQuotaCache(); - expect(loaded.byAccountId.acc_1?.model).toBe("gpt-5-codex"); - expect(attempts).toBe(2); - } finally { - readSpy.mockRestore(); - } - }); - - it.each(["EBUSY", "EPERM"] as const)( - "retries atomic rename on transient %s errors", - async (code) => { - const { saveQuotaCache, loadQuotaCache } = await import("../lib/quota-cache.js"); - const realRename = fs.rename; - const renameSpy = vi.spyOn(fs, "rename"); - let attempts = 0; - renameSpy.mockImplementation(async (...args) => { - attempts += 1; - if (attempts < 3) { - const error = new Error(`rename failed: ${code}`) as NodeJS.ErrnoException; - error.code = code; - throw error; - } - return realRename(...args); - }); - - try { - await saveQuotaCache({ - byAccountId: { - acc_1: { - updatedAt: Date.now(), - status: 200, - model: "gpt-5-codex", - primary: { usedPercent: 40, windowMinutes: 300 }, - secondary: { usedPercent: 20, windowMinutes: 10080 }, - }, - }, - byEmail: {}, - }); - const loaded = await loadQuotaCache(); - expect(loaded.byAccountId.acc_1?.model).toBe("gpt-5-codex"); - expect(attempts).toBe(3); - } finally { - renameSpy.mockRestore(); - } - }, - ); - - it("cleans up temp files when rename keeps failing", async () => { - const { saveQuotaCache } = await import("../lib/quota-cache.js"); - const renameSpy = vi.spyOn(fs, "rename"); - const unlinkSpy = vi.spyOn(fs, "unlink"); - renameSpy.mockImplementation(async () => { - const error = new Error("locked") as NodeJS.ErrnoException; - error.code = "EPERM"; - throw error; - }); - - try { - await saveQuotaCache({ - byAccountId: { - acc_1: { - updatedAt: Date.now(), - status: 200, - model: "gpt-5-codex", - primary: { usedPercent: 40, windowMinutes: 300 }, - secondary: { usedPercent: 20, windowMinutes: 10080 }, - }, - }, - byEmail: {}, - }); - - expect(unlinkSpy).toHaveBeenCalledTimes(1); - const entries = await fs.readdir(tempDir); - expect(entries.some((entry) => entry.endsWith(".tmp"))).toBe(false); - } finally { - unlinkSpy.mockRestore(); - renameSpy.mockRestore(); - } - }); - - it("logs sanitized cache filename for load/save failures", async () => { - vi.resetModules(); - const warnMock = vi.fn(); - vi.doMock("../lib/logger.js", () => ({ - logWarn: warnMock, - })); - try { - const { getQuotaCachePath, loadQuotaCache, saveQuotaCache } = await import( - "../lib/quota-cache.js" - ); - await fs.writeFile(getQuotaCachePath(), "{}", "utf8"); - - const readSpy = vi.spyOn(fs, "readFile"); - readSpy.mockRejectedValueOnce(new Error("read failed")); - await loadQuotaCache(); - readSpy.mockRestore(); - - const renameSpy = vi.spyOn(fs, "rename"); - renameSpy.mockImplementation(async () => { - const error = new Error("rename failed") as NodeJS.ErrnoException; - error.code = "EIO"; - throw error; - }); - await saveQuotaCache({ byAccountId: {}, byEmail: {} }); - renameSpy.mockRestore(); - - const logMessages = warnMock.mock.calls.map((args) => String(args[0])); - expect(logMessages.some((message) => message.includes("quota-cache.json"))).toBe(true); - expect(logMessages.some((message) => message.includes(tempDir))).toBe(false); - } finally { - vi.doUnmock("../lib/logger.js"); - } - }); + let tempDir: string; + let originalDir: string | undefined; + + beforeEach(async () => { + originalDir = process.env.CODEX_MULTI_AUTH_DIR; + tempDir = await fs.mkdtemp(join(tmpdir(), "codex-multi-auth-quota-")); + process.env.CODEX_MULTI_AUTH_DIR = tempDir; + vi.resetModules(); + }); + + afterEach(async () => { + if (originalDir === undefined) { + delete process.env.CODEX_MULTI_AUTH_DIR; + } else { + process.env.CODEX_MULTI_AUTH_DIR = originalDir; + } + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it("returns empty cache by default", async () => { + const { loadQuotaCache } = await import("../lib/quota-cache.js"); + const data = await loadQuotaCache(); + expect(data).toEqual({ byAccountId: {}, byEmail: {} }); + }); + + it("saves and reloads quota entries", async () => { + const { loadQuotaCache, saveQuotaCache, getQuotaCachePath } = + await import("../lib/quota-cache.js"); + + await saveQuotaCache({ + byAccountId: { + acc_1: { + updatedAt: Date.now(), + status: 200, + model: "gpt-5-codex", + planType: "plus", + primary: { usedPercent: 40, windowMinutes: 300 }, + secondary: { usedPercent: 20, windowMinutes: 10080 }, + }, + }, + byEmail: {}, + }); + + const loaded = await loadQuotaCache(); + expect(loaded.byAccountId.acc_1?.primary.usedPercent).toBe(40); + + const fileContent = await fs.readFile(getQuotaCachePath(), "utf8"); + expect(fileContent).toContain('"version": 1'); + }); + + it("ignores cache files with unsupported version", async () => { + const { loadQuotaCache, getQuotaCachePath } = + await import("../lib/quota-cache.js"); + await fs.writeFile( + getQuotaCachePath(), + JSON.stringify({ + version: 2, + byAccountId: { + acc_1: { + updatedAt: Date.now(), + status: 200, + model: "gpt-5-codex", + primary: { usedPercent: 10 }, + secondary: { usedPercent: 5 }, + }, + }, + byEmail: {}, + }), + "utf8", + ); + + const loaded = await loadQuotaCache(); + expect(loaded).toEqual({ byAccountId: {}, byEmail: {} }); + }); + + it("retries transient EBUSY while loading cache", async () => { + const { loadQuotaCache, getQuotaCachePath } = + await import("../lib/quota-cache.js"); + await fs.writeFile( + getQuotaCachePath(), + JSON.stringify({ + version: 1, + byAccountId: { + acc_1: { + updatedAt: Date.now(), + status: 200, + model: "gpt-5-codex", + primary: { usedPercent: 10 }, + secondary: { usedPercent: 5 }, + }, + }, + byEmail: {}, + }), + "utf8", + ); + + const realRead = fs.readFile.bind(fs); + let attempts = 0; + const readSpy = vi.spyOn(fs, "readFile"); + readSpy.mockImplementation(async (...args) => { + if (String(args[0]) === getQuotaCachePath()) { + attempts += 1; + if (attempts === 1) { + const error = new Error("busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + } + return realRead(...args); + }); + + try { + const loaded = await loadQuotaCache(); + expect(loaded.byAccountId.acc_1?.model).toBe("gpt-5-codex"); + expect(attempts).toBe(2); + } finally { + readSpy.mockRestore(); + } + }); + + it.each(["EBUSY", "EPERM"] as const)( + "retries atomic rename on transient %s errors", + async (code) => { + const { saveQuotaCache, loadQuotaCache } = + await import("../lib/quota-cache.js"); + const realRename = fs.rename; + const renameSpy = vi.spyOn(fs, "rename"); + let attempts = 0; + renameSpy.mockImplementation(async (...args) => { + attempts += 1; + if (attempts < 3) { + const error = new Error( + `rename failed: ${code}`, + ) as NodeJS.ErrnoException; + error.code = code; + throw error; + } + return realRename(...args); + }); + + try { + await saveQuotaCache({ + byAccountId: { + acc_1: { + updatedAt: Date.now(), + status: 200, + model: "gpt-5-codex", + primary: { usedPercent: 40, windowMinutes: 300 }, + secondary: { usedPercent: 20, windowMinutes: 10080 }, + }, + }, + byEmail: {}, + }); + const loaded = await loadQuotaCache(); + expect(loaded.byAccountId.acc_1?.model).toBe("gpt-5-codex"); + expect(attempts).toBe(3); + } finally { + renameSpy.mockRestore(); + } + }, + ); + + it("cleans up temp files when rename keeps failing", async () => { + const { saveQuotaCache } = await import("../lib/quota-cache.js"); + const renameSpy = vi.spyOn(fs, "rename"); + const unlinkSpy = vi.spyOn(fs, "unlink"); + renameSpy.mockImplementation(async () => { + const error = new Error("locked") as NodeJS.ErrnoException; + error.code = "EPERM"; + throw error; + }); + + try { + await saveQuotaCache({ + byAccountId: { + acc_1: { + updatedAt: Date.now(), + status: 200, + model: "gpt-5-codex", + primary: { usedPercent: 40, windowMinutes: 300 }, + secondary: { usedPercent: 20, windowMinutes: 10080 }, + }, + }, + byEmail: {}, + }); + + expect(unlinkSpy).toHaveBeenCalledTimes(1); + const entries = await fs.readdir(tempDir); + expect(entries.some((entry) => entry.endsWith(".tmp"))).toBe(false); + } finally { + unlinkSpy.mockRestore(); + renameSpy.mockRestore(); + } + }); + + it("logs sanitized cache filename for load/save failures", async () => { + vi.resetModules(); + const warnMock = vi.fn(); + vi.doMock("../lib/logger.js", () => ({ + logWarn: warnMock, + })); + try { + const { getQuotaCachePath, loadQuotaCache, saveQuotaCache } = + await import("../lib/quota-cache.js"); + await fs.writeFile(getQuotaCachePath(), "{}", "utf8"); + + const readSpy = vi.spyOn(fs, "readFile"); + readSpy.mockRejectedValueOnce(new Error("read failed")); + await loadQuotaCache(); + readSpy.mockRestore(); + + const renameSpy = vi.spyOn(fs, "rename"); + renameSpy.mockImplementation(async () => { + const error = new Error("rename failed") as NodeJS.ErrnoException; + error.code = "EIO"; + throw error; + }); + await saveQuotaCache({ byAccountId: {}, byEmail: {} }); + renameSpy.mockRestore(); + + const logMessages = warnMock.mock.calls.map((args) => String(args[0])); + expect( + logMessages.some((message) => message.includes("quota-cache.json")), + ).toBe(true); + expect(logMessages.some((message) => message.includes(tempDir))).toBe( + false, + ); + } finally { + vi.doUnmock("../lib/logger.js"); + } + }); + it("normalizes mixed valid and invalid cached entries", async () => { + const { loadQuotaCache, getQuotaCachePath } = + await import("../lib/quota-cache.js"); + await fs.writeFile( + getQuotaCachePath(), + JSON.stringify({ + version: 1, + byAccountId: { + "": { + updatedAt: Date.now(), + status: 200, + model: "should-be-dropped", + primary: {}, + secondary: {}, + }, + good: { + updatedAt: Date.now(), + status: 200, + model: " gpt-5-codex ", + planType: "plus", + primary: { + usedPercent: 55, + windowMinutes: 60, + resetAtMs: Date.now() + 1_000, + }, + secondary: { usedPercent: 10, windowMinutes: 10_080 }, + }, + badType: "not-an-entry", + missingUpdated: { + status: 200, + model: "missing-updated", + primary: {}, + secondary: {}, + }, + nonStringModel: { + updatedAt: Date.now(), + status: 200, + model: 123, + primary: {}, + secondary: {}, + }, + invalidWindow: { + updatedAt: Date.now(), + status: 200, + model: " model-edge ", + planType: 123, + primary: "invalid-window", + secondary: { + usedPercent: "bad", + windowMinutes: 120, + resetAtMs: Infinity, + }, + }, + }, + byEmail: [], + }), + "utf8", + ); + + const loaded = await loadQuotaCache(); + expect(Object.keys(loaded.byAccountId)).toEqual(["good", "invalidWindow"]); + expect(loaded.byAccountId.good?.model).toBe("gpt-5-codex"); + expect(loaded.byAccountId.good?.planType).toBe("plus"); + expect(loaded.byAccountId.invalidWindow?.planType).toBeUndefined(); + expect(loaded.byAccountId.invalidWindow?.primary).toEqual({}); + expect(loaded.byAccountId.invalidWindow?.secondary).toEqual({ + windowMinutes: 120, + }); + expect(loaded.byEmail).toEqual({}); + }); + + it("returns empty cache when parsed payload is not an object", async () => { + const { loadQuotaCache, getQuotaCachePath } = + await import("../lib/quota-cache.js"); + await fs.writeFile(getQuotaCachePath(), "[]", "utf8"); + + const loaded = await loadQuotaCache(); + expect(loaded).toEqual({ byAccountId: {}, byEmail: {} }); + }); + + it("logs stringified non-Error load/save failures", async () => { + vi.resetModules(); + const warnMock = vi.fn(); + vi.doMock("../lib/logger.js", () => ({ + logWarn: warnMock, + })); + try { + const { getQuotaCachePath, loadQuotaCache, saveQuotaCache } = + await import("../lib/quota-cache.js"); + await fs.writeFile(getQuotaCachePath(), "{}", "utf8"); + + const readSpy = vi.spyOn(fs, "readFile"); + readSpy.mockRejectedValueOnce("string-read-failure"); + await loadQuotaCache(); + readSpy.mockRestore(); + + const mkdirSpy = vi.spyOn(fs, "mkdir"); + mkdirSpy.mockRejectedValueOnce("mkdir-string-failure"); + await saveQuotaCache({ byAccountId: {}, byEmail: {} }); + mkdirSpy.mockRestore(); + + const messages = warnMock.mock.calls.map((args) => String(args[0])); + expect( + messages.some((message) => message.includes("string-read-failure")), + ).toBe(true); + expect( + messages.some((message) => message.includes("mkdir-string-failure")), + ).toBe(true); + } finally { + vi.doUnmock("../lib/logger.js"); + } + }); }); diff --git a/test/quota-probe.test.ts b/test/quota-probe.test.ts index efab28b7..96e9605f 100644 --- a/test/quota-probe.test.ts +++ b/test/quota-probe.test.ts @@ -170,4 +170,241 @@ describe("quota-probe", () => { await assertion; expect(fetchMock).toHaveBeenCalledTimes(1); }); + it("parses reset-at values expressed as epoch seconds and epoch milliseconds", async () => { + const nowSec = Math.floor(Date.now() / 1000); + const primarySeconds = nowSec + 120; + const secondaryMs = Date.now() + 180_000; + const headers = new Headers({ + "x-codex-primary-used-percent": "10", + "x-codex-primary-window-minutes": "60", + "x-codex-primary-reset-at": String(primarySeconds), + "x-codex-secondary-used-percent": "20", + "x-codex-secondary-window-minutes": "120", + "x-codex-secondary-reset-at": String(secondaryMs), + }); + const fetchMock = vi.fn(async () => new Response("", { status: 200, headers })); + vi.stubGlobal("fetch", fetchMock); + + const snapshot = await fetchCodexQuotaSnapshot({ + accountId: "acc-epoch", + accessToken: "token-epoch", + model: "gpt-5-codex", + fallbackModels: [], + }); + + expect(snapshot.primary.resetAtMs).toBe(primarySeconds * 1000); + expect(snapshot.secondary.resetAtMs).toBe(secondaryMs); + }); + + it("keeps resetAt undefined for invalid reset-at values", async () => { + const headers = new Headers({ + "x-codex-primary-used-percent": "not-a-number", + "x-codex-primary-window-minutes": "60", + "x-codex-primary-reset-at": "not-a-date", + "x-codex-secondary-used-percent": "30", + "x-codex-secondary-window-minutes": "90", + "x-codex-secondary-reset-at": "", + }); + const fetchMock = vi.fn(async () => new Response("", { status: 200, headers })); + vi.stubGlobal("fetch", fetchMock); + + const snapshot = await fetchCodexQuotaSnapshot({ + accountId: "acc-invalid-reset", + accessToken: "token-invalid-reset", + model: "gpt-5-codex", + fallbackModels: [], + }); + + expect(snapshot.primary.usedPercent).toBeUndefined(); + expect(snapshot.primary.resetAtMs).toBeUndefined(); + expect(snapshot.secondary.resetAtMs).toBeUndefined(); + }); + + it("throws parsed nested error.message for non-ok response without quota headers", async () => { + const fetchMock = vi.fn(async () => + new Response(JSON.stringify({ error: { message: "nested failure" } }), { + status: 500, + headers: new Headers({ "content-type": "application/json" }), + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await expect( + fetchCodexQuotaSnapshot({ + accountId: "acc-error", + accessToken: "token-error", + model: "gpt-5-codex", + fallbackModels: [], + }), + ).rejects.toThrow("nested failure"); + }); + + it("throws top-level message/plain text/HTTP fallback for non-ok response", async () => { + const topLevel = vi.fn(async () => + new Response(JSON.stringify({ message: "top-level failure" }), { + status: 502, + headers: new Headers({ "content-type": "application/json" }), + }), + ); + vi.stubGlobal("fetch", topLevel); + await expect( + fetchCodexQuotaSnapshot({ + accountId: "acc-msg", + accessToken: "token-msg", + model: "gpt-5-codex", + fallbackModels: [], + }), + ).rejects.toThrow("top-level failure"); + + const plainText = vi.fn(async () => + new Response("plain-text failure", { + status: 503, + headers: new Headers({ "content-type": "text/plain" }), + }), + ); + vi.stubGlobal("fetch", plainText); + await expect( + fetchCodexQuotaSnapshot({ + accountId: "acc-plain", + accessToken: "token-plain", + model: "gpt-5-codex", + fallbackModels: [], + }), + ).rejects.toThrow("plain-text failure"); + + const emptyBody = vi.fn(async () => new Response("", { status: 504 })); + vi.stubGlobal("fetch", emptyBody); + await expect( + fetchCodexQuotaSnapshot({ + accountId: "acc-empty", + accessToken: "token-empty", + model: "gpt-5-codex", + fallbackModels: [], + }), + ).rejects.toThrow("HTTP 504"); + }); + + it("uses default unsupported-model message when helper does not provide one", async () => { + getUnsupportedCodexModelInfoMock.mockReturnValue({ + isUnsupported: true, + unsupportedModel: "gpt-5-codex", + message: undefined, + }); + const fetchMock = vi.fn(async () => + new Response(JSON.stringify({ error: { message: "unsupported model" } }), { + status: 400, + headers: new Headers({ "content-type": "application/json" }), + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await expect( + fetchCodexQuotaSnapshot({ + accountId: "acc-unsupported", + accessToken: "token-unsupported", + model: "gpt-5-codex", + fallbackModels: [], + }), + ).rejects.toThrow("Model 'gpt-5-codex' unsupported for this account"); + }); + + it("throws generic failure when no normalized probe models are available", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + await expect( + fetchCodexQuotaSnapshot({ + accountId: "acc-none", + accessToken: "token-none", + model: " ", + fallbackModels: ["", " "], + }), + ).rejects.toThrow("Failed to fetch quotas"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("converts non-Error thrown values into Error instances", async () => { + const fetchMock = vi.fn(async () => { + throw "string failure"; + }); + vi.stubGlobal("fetch", fetchMock); + + await expect( + fetchCodexQuotaSnapshot({ + accountId: "acc-string", + accessToken: "token-string", + model: "gpt-5-codex", + fallbackModels: [], + }), + ).rejects.toThrow("string failure"); + }); + + it("throws missing-quota-header error when response succeeds without quota headers", async () => { + const fetchMock = vi.fn(async () => new Response("ok-no-headers", { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + await expect( + fetchCodexQuotaSnapshot({ + accountId: "acc-no-headers", + accessToken: "token-no-headers", + model: "gpt-5-codex", + fallbackModels: [], + }), + ).rejects.toThrow("Codex response did not include quota headers"); + }); + + it("formats quota lines with day/hour labels, reset text, plan, and active limits", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-02T12:00:00.000Z")); + + const line = formatQuotaSnapshotLine({ + status: 429, + planType: "pro", + activeLimit: 4, + model: "gpt-5-codex", + primary: { + windowMinutes: 1440, + usedPercent: 60, + resetAtMs: new Date("2026-03-02T13:00:00.000Z").getTime(), + }, + secondary: { + windowMinutes: 60, + usedPercent: 10, + resetAtMs: new Date("2026-03-03T14:00:00.000Z").getTime(), + }, + }); + + expect(line).toContain("1d"); + expect(line).toContain("1h"); + expect(line).toContain("resets"); + expect(line).toContain("on "); + expect(line).toContain("plan:pro"); + expect(line).toContain("active:4"); + expect(line).toContain("rate-limited"); + }); + + it("formats fallback quota labels and suppresses invalid reset time", () => { + const line = formatQuotaSnapshotLine({ + status: 200, + model: "gpt-5-codex", + primary: { + windowMinutes: 0, + usedPercent: 150, + resetAtMs: Number.NaN, + }, + secondary: { + windowMinutes: 15, + usedPercent: -20, + resetAtMs: undefined, + }, + planType: undefined, + activeLimit: Number.NaN, + }); + + expect(line).toContain("quota"); + expect(line).toContain("15m"); + expect(line).not.toContain("resets"); + expect(line).not.toContain("plan:"); + expect(line).not.toContain("active:"); + }); }); diff --git a/test/rate-limit-backoff.test.ts b/test/rate-limit-backoff.test.ts index 50ba83a1..107772f8 100644 --- a/test/rate-limit-backoff.test.ts +++ b/test/rate-limit-backoff.test.ts @@ -154,5 +154,52 @@ describe("Rate limit backoff", () => { expect(second.attempt).toBe(2); expect(second.delayMs).toBe(12000); }); + + it("supports named-parameter options form", () => { + const positional = getRateLimitBackoffWithReason(20, "named-quota", 1000, "tokens"); + clearRateLimitBackoffState(); + const named = getRateLimitBackoffWithReason({ + accountIndex: 20, + quotaKey: "named-quota", + serverRetryAfterMs: 1000, + reason: "tokens", + }); + expect(named).toEqual(positional); + }); + + it("throws for invalid named accountIndex values", () => { + expect(() => + getRateLimitBackoffWithReason({ + accountIndex: -1, + quotaKey: "invalid-index", + serverRetryAfterMs: 1000, + }), + ).toThrowError( + "getRateLimitBackoffWithReason requires a non-negative integer accountIndex", + ); + expect(() => + getRateLimitBackoffWithReason({ + accountIndex: Number.NaN, + quotaKey: "invalid-index", + serverRetryAfterMs: 1000, + }), + ).toThrowError( + "getRateLimitBackoffWithReason requires a non-negative integer accountIndex", + ); + }); + + it("does not mutate shared state when named accountIndex is invalid", () => { + expect(() => + getRateLimitBackoffWithReason({ + accountIndex: -5, + quotaKey: "state-safe", + serverRetryAfterMs: 1000, + }), + ).toThrow(); + + const firstValid = getRateLimitBackoffWithReason(7, "state-safe", 1000, "unknown"); + expect(firstValid.attempt).toBe(1); + expect(firstValid.isDuplicate).toBe(false); + }); }); }); diff --git a/test/refresh-guardian.test.ts b/test/refresh-guardian.test.ts index 678a6ee3..9584d5fe 100644 --- a/test/refresh-guardian.test.ts +++ b/test/refresh-guardian.test.ts @@ -5,348 +5,615 @@ const refreshExpiringAccountsMock = vi.fn(); const applyRefreshResultMock = vi.fn(); vi.mock("../lib/proactive-refresh.js", () => ({ - refreshExpiringAccounts: refreshExpiringAccountsMock, - applyRefreshResult: applyRefreshResultMock, + refreshExpiringAccounts: refreshExpiringAccountsMock, + applyRefreshResult: applyRefreshResultMock, })); function createManagedAccount(index: number): ManagedAccount { - return { - index, - refreshToken: `refresh-${index}`, - addedAt: Date.now() - 10_000, - lastUsed: Date.now() - 5_000, - rateLimitResetTimes: {}, - enabled: true, - }; + return { + index, + refreshToken: `refresh-${index}`, + addedAt: Date.now() - 10_000, + lastUsed: Date.now() - 5_000, + rateLimitResetTimes: {}, + enabled: true, + }; } function createManagerMock(accounts: ManagedAccount[]): AccountManager { - return { - getAccountsSnapshot: vi.fn(() => accounts), - getAccountByIndex: vi.fn((index: number) => accounts.find((account) => account.index === index) ?? null), - clearAuthFailures: vi.fn(), - markAccountCoolingDown: vi.fn(), - setAccountEnabled: vi.fn(), - saveToDiskDebounced: vi.fn(), - } as unknown as AccountManager; + return { + getAccountsSnapshot: vi.fn(() => accounts), + getAccountByIndex: vi.fn( + (index: number) => + accounts.find((account) => account.index === index) ?? null, + ), + clearAuthFailures: vi.fn(), + markAccountCoolingDown: vi.fn(), + setAccountEnabled: vi.fn(), + saveToDiskDebounced: vi.fn(), + } as unknown as AccountManager; } describe("refresh-guardian", () => { - beforeEach(() => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-02-26T00:00:00.000Z")); - refreshExpiringAccountsMock.mockReset(); - applyRefreshResultMock.mockReset(); - }); - - afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); - }); - - it("applies refresh outcomes and updates stats", async () => { - const accountA = createManagedAccount(0); - const accountB = createManagedAccount(1); - const manager = createManagerMock([accountA, accountB]); - const { RefreshGuardian } = await import("../lib/refresh-guardian.js"); - const guardian = new RefreshGuardian(() => manager, { bufferMs: 60_000, intervalMs: 5_000 }); - - refreshExpiringAccountsMock.mockResolvedValue( - new Map([ - [ - 0, - { - refreshed: true, - reason: "success", - tokenResult: { - type: "success", - access: "access-0", - refresh: "refresh-0-new", - expires: Date.now() + 3_600_000, - }, - }, - ], - [ - 1, - { - refreshed: true, - reason: "failed", - tokenResult: { - type: "failed", - reason: "invalid_response", - message: "invalid payload", - }, - }, - ], - ]), - ); - - await guardian.tick(); - - expect(refreshExpiringAccountsMock).toHaveBeenCalledTimes(1); - expect(applyRefreshResultMock).toHaveBeenCalledTimes(1); - expect(applyRefreshResultMock).toHaveBeenCalledWith( - accountA, - expect.objectContaining({ type: "success" }), - ); - expect((manager.clearAuthFailures as ReturnType)).toHaveBeenCalledWith(accountA); - expect((manager.markAccountCoolingDown as ReturnType)).toHaveBeenCalledWith( - accountB, - 60_000, - "auth-failure", - ); - expect((manager.saveToDiskDebounced as ReturnType)).toHaveBeenCalledTimes(1); - - const stats = guardian.getStats(); - expect(stats.runs).toBe(1); - expect(stats.refreshed).toBe(1); - expect(stats.failed).toBe(1); - expect(stats.authFailed).toBe(1); - expect(stats.networkFailed).toBe(0); - expect(stats.rateLimited).toBe(0); - expect(stats.notNeeded).toBe(0); - expect(stats.noRefreshToken).toBe(0); - expect(stats.lastRunAt).not.toBeNull(); - }); - - it("skips overlapping tick executions", async () => { - const accountA = createManagedAccount(0); - const manager = createManagerMock([accountA]); - const { RefreshGuardian } = await import("../lib/refresh-guardian.js"); - const guardian = new RefreshGuardian(() => manager, { intervalMs: 5_000 }); - - let release: (() => void) | null = null; - const pending = new Promise>((resolve) => { - release = () => resolve(new Map()); - }); - refreshExpiringAccountsMock.mockReturnValue(pending); - - const first = guardian.tick(); - const second = guardian.tick(); - expect(refreshExpiringAccountsMock).toHaveBeenCalledTimes(1); - - release?.(); - await first; - await second; - }); - - it("runs on interval start and stops cleanly", async () => { - const manager = createManagerMock([]); - const { RefreshGuardian } = await import("../lib/refresh-guardian.js"); - const guardian = new RefreshGuardian(() => manager, { intervalMs: 5_000 }); - const tickSpy = vi.spyOn(guardian, "tick").mockResolvedValue(undefined); - - guardian.start(); - await vi.advanceTimersByTimeAsync(5_000); - expect(tickSpy).toHaveBeenCalledTimes(1); - - guardian.stop(); - await vi.advanceTimersByTimeAsync(15_000); - expect(tickSpy).toHaveBeenCalledTimes(1); - }); - - it("resolves refreshed account using stable refresh token when indices shift", async () => { - const originalA = createManagedAccount(0); - const originalB = createManagedAccount(1); - const liveB = { ...originalB, index: 0 }; - const liveA = { ...originalA, index: 1 }; - const snapshots = [[originalA, originalB], [liveB, liveA]]; - let readCount = 0; - const manager = { - getAccountsSnapshot: vi.fn(() => snapshots[Math.min(readCount++, snapshots.length - 1)]), - getAccountByIndex: vi.fn((index: number) => [liveB, liveA].find((account) => account.index === index) ?? null), - clearAuthFailures: vi.fn(), - markAccountCoolingDown: vi.fn(), - saveToDiskDebounced: vi.fn(), - } as unknown as AccountManager; - const { RefreshGuardian } = await import("../lib/refresh-guardian.js"); - const guardian = new RefreshGuardian(() => manager, { bufferMs: 60_000, intervalMs: 5_000 }); - - refreshExpiringAccountsMock.mockResolvedValue( - new Map([ - [ - 1, - { - refreshed: true, - reason: "success", - tokenResult: { - type: "success", - access: "access-shifted", - refresh: "refresh-shifted", - expires: Date.now() + 3_600_000, - }, - }, - ], - ]), - ); - - await guardian.tick(); - - expect(applyRefreshResultMock).toHaveBeenCalledTimes(1); - expect(applyRefreshResultMock).toHaveBeenCalledWith( - liveB, - expect.objectContaining({ type: "success" }), - ); - expect((manager.clearAuthFailures as ReturnType)).toHaveBeenCalledWith(liveB); - }); - - it("classifies failure reasons and handles no-op branches", async () => { - const accountA = createManagedAccount(0); - const accountB = createManagedAccount(1); - const accountC = createManagedAccount(2); - const accountD = createManagedAccount(3); - const accountE = createManagedAccount(4); - const manager = createManagerMock([accountA, accountB, accountC, accountD, accountE]); - const { RefreshGuardian } = await import("../lib/refresh-guardian.js"); - const guardian = new RefreshGuardian(() => manager, { bufferMs: 60_000, intervalMs: 5_000 }); - - refreshExpiringAccountsMock.mockResolvedValue( - new Map([ - [0, { refreshed: false, reason: "not_needed" }], - [1, { refreshed: false, reason: "no_refresh_token" }], - [ - 2, - { - refreshed: true, - reason: "failed", - tokenResult: { - type: "failed", - reason: "http_error", - statusCode: 429, - message: "rate limited", - }, - }, - ], - [ - 3, - { - refreshed: true, - reason: "failed", - tokenResult: { - type: "failed", - reason: "network_error", - message: "timeout", - }, - }, - ], - [ - 4, - { - refreshed: true, - reason: "failed", - tokenResult: { - type: "failed", - reason: "http_error", - statusCode: 401, - message: "expired", - }, - }, - ], - ]), - ); - - await guardian.tick(); - - expect((manager.setAccountEnabled as ReturnType)).toHaveBeenCalledWith(1, false); - expect((manager.markAccountCoolingDown as ReturnType)).toHaveBeenNthCalledWith( - 1, - accountB, - 60_000, - "auth-failure", - ); - expect((manager.markAccountCoolingDown as ReturnType)).toHaveBeenNthCalledWith( - 2, - accountC, - 60_000, - "rate-limit", - ); - expect((manager.markAccountCoolingDown as ReturnType)).toHaveBeenNthCalledWith( - 3, - accountD, - 60_000, - "network-error", - ); - expect((manager.markAccountCoolingDown as ReturnType)).toHaveBeenNthCalledWith( - 4, - accountE, - 60_000, - "auth-failure", - ); - - const stats = guardian.getStats(); - expect(stats.runs).toBe(1); - expect(stats.refreshed).toBe(0); - expect(stats.failed).toBe(4); - expect(stats.notNeeded).toBe(1); - expect(stats.noRefreshToken).toBe(1); - expect(stats.rateLimited).toBe(1); - expect(stats.networkFailed).toBe(1); - expect(stats.authFailed).toBe(2); - }); - - it("handles account removal during tick without throwing", async () => { - const originalA = createManagedAccount(0); - const originalB = createManagedAccount(1); - const liveAfterRemoval = [{ ...originalB }]; - let snapshotReads = 0; - - const manager = { - getAccountsSnapshot: vi.fn(() => { - snapshotReads += 1; - if (snapshotReads === 1) return [originalA, originalB]; - return liveAfterRemoval; - }), - getAccountByIndex: vi.fn((index: number) => liveAfterRemoval[index] ?? null), - clearAuthFailures: vi.fn(), - markAccountCoolingDown: vi.fn(), - setAccountEnabled: vi.fn(), - saveToDiskDebounced: vi.fn(), - } as unknown as AccountManager; - - const { RefreshGuardian } = await import("../lib/refresh-guardian.js"); - const guardian = new RefreshGuardian(() => manager, { bufferMs: 60_000, intervalMs: 5_000 }); - - refreshExpiringAccountsMock.mockResolvedValue( - new Map([ - [ - 0, - { - refreshed: true, - reason: "success", - tokenResult: { - type: "success", - access: "removed-access", - refresh: "removed-refresh", - expires: Date.now() + 3_600_000, - }, - }, - ], - [ - 1, - { - refreshed: true, - reason: "failed", - tokenResult: { - type: "failed", - reason: "http_error", - statusCode: 429, - message: "rate limit", - }, - }, - ], - ]), - ); - - await expect(guardian.tick()).resolves.toBeUndefined(); - expect(applyRefreshResultMock).not.toHaveBeenCalledWith( - expect.objectContaining({ refreshToken: originalA.refreshToken }), - expect.anything(), - ); - expect((manager.markAccountCoolingDown as ReturnType)).toHaveBeenCalledWith( - expect.objectContaining({ refreshToken: originalB.refreshToken }), - 60_000, - "rate-limit", - ); - }); + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-02-26T00:00:00.000Z")); + refreshExpiringAccountsMock.mockReset(); + applyRefreshResultMock.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("clamps defaults, ignores duplicate start calls, and allows idempotent stop", async () => { + const accountA = createManagedAccount(0); + const manager = createManagerMock([accountA]); + const { RefreshGuardian } = await import("../lib/refresh-guardian.js"); + const guardian = new RefreshGuardian(() => manager); + const tickSpy = vi.spyOn(guardian, "tick").mockResolvedValue(undefined); + + expect(Reflect.get(guardian, "intervalMs")).toBe(60_000); + expect(Reflect.get(guardian, "bufferMs")).toBe(300_000); + + guardian.start(); + guardian.start(); + await vi.advanceTimersByTimeAsync(60_000); + expect(tickSpy).toHaveBeenCalledTimes(1); + + guardian.stop(); + guardian.stop(); + }); + + it("returns early when manager is missing or no accounts are enabled", async () => { + const { RefreshGuardian } = await import("../lib/refresh-guardian.js"); + const missingManagerGuardian = new RefreshGuardian(() => null, { + intervalMs: 5_000, + }); + await expect(missingManagerGuardian.tick()).resolves.toBeUndefined(); + expect(missingManagerGuardian.getStats().runs).toBe(0); + + const disabledManager = createManagerMock([ + { ...createManagedAccount(0), enabled: false }, + { ...createManagedAccount(1), enabled: false }, + ]); + const disabledGuardian = new RefreshGuardian(() => disabledManager, { + intervalMs: 5_000, + bufferMs: 60_000, + }); + await expect(disabledGuardian.tick()).resolves.toBeUndefined(); + expect(refreshExpiringAccountsMock).not.toHaveBeenCalled(); + expect(disabledGuardian.getStats().runs).toBe(0); + }); + + it("records run stats when no accounts require proactive refresh", async () => { + const accountA = createManagedAccount(0); + const manager = createManagerMock([accountA]); + const { RefreshGuardian } = await import("../lib/refresh-guardian.js"); + const guardian = new RefreshGuardian(() => manager, { + intervalMs: 5_000, + bufferMs: 60_000, + }); + + refreshExpiringAccountsMock.mockResolvedValue(new Map()); + + await guardian.tick(); + + expect( + manager.saveToDiskDebounced as ReturnType, + ).not.toHaveBeenCalled(); + const stats = guardian.getStats(); + expect(stats.runs).toBe(1); + expect(stats.lastRunAt).not.toBeNull(); + }); + + it("applies refresh outcomes and updates stats", async () => { + const accountA = createManagedAccount(0); + const accountB = createManagedAccount(1); + const manager = createManagerMock([accountA, accountB]); + const { RefreshGuardian } = await import("../lib/refresh-guardian.js"); + const guardian = new RefreshGuardian(() => manager, { + bufferMs: 60_000, + intervalMs: 5_000, + }); + + refreshExpiringAccountsMock.mockResolvedValue( + new Map([ + [ + 0, + { + refreshed: true, + reason: "success", + tokenResult: { + type: "success", + access: "access-0", + refresh: "refresh-0-new", + expires: Date.now() + 3_600_000, + }, + }, + ], + [ + 1, + { + refreshed: true, + reason: "failed", + tokenResult: { + type: "failed", + reason: "invalid_response", + message: "invalid payload", + }, + }, + ], + ]), + ); + + await guardian.tick(); + + expect(refreshExpiringAccountsMock).toHaveBeenCalledTimes(1); + expect(applyRefreshResultMock).toHaveBeenCalledTimes(1); + expect(applyRefreshResultMock).toHaveBeenCalledWith( + accountA, + expect.objectContaining({ type: "success" }), + ); + expect( + manager.clearAuthFailures as ReturnType, + ).toHaveBeenCalledWith(accountA); + expect( + manager.markAccountCoolingDown as ReturnType, + ).toHaveBeenCalledWith(accountB, 60_000, "auth-failure"); + expect( + manager.saveToDiskDebounced as ReturnType, + ).toHaveBeenCalledTimes(1); + + const stats = guardian.getStats(); + expect(stats.runs).toBe(1); + expect(stats.refreshed).toBe(1); + expect(stats.failed).toBe(1); + expect(stats.authFailed).toBe(1); + expect(stats.networkFailed).toBe(0); + expect(stats.rateLimited).toBe(0); + expect(stats.notNeeded).toBe(0); + expect(stats.noRefreshToken).toBe(0); + expect(stats.lastRunAt).not.toBeNull(); + }); + + it("skips overlapping tick executions", async () => { + const accountA = createManagedAccount(0); + const manager = createManagerMock([accountA]); + const { RefreshGuardian } = await import("../lib/refresh-guardian.js"); + const guardian = new RefreshGuardian(() => manager, { intervalMs: 5_000 }); + + let release: (() => void) | null = null; + const pending = new Promise>((resolve) => { + release = () => resolve(new Map()); + }); + refreshExpiringAccountsMock.mockReturnValue(pending); + + const first = guardian.tick(); + const second = guardian.tick(); + expect(refreshExpiringAccountsMock).toHaveBeenCalledTimes(1); + + release?.(); + await first; + await second; + }); + + it("runs on interval start and stops cleanly", async () => { + const manager = createManagerMock([]); + const { RefreshGuardian } = await import("../lib/refresh-guardian.js"); + const guardian = new RefreshGuardian(() => manager, { intervalMs: 5_000 }); + const tickSpy = vi.spyOn(guardian, "tick").mockResolvedValue(undefined); + + guardian.start(); + await vi.advanceTimersByTimeAsync(5_000); + expect(tickSpy).toHaveBeenCalledTimes(1); + + guardian.stop(); + await vi.advanceTimersByTimeAsync(15_000); + expect(tickSpy).toHaveBeenCalledTimes(1); + }); + + it("resolves refreshed account using stable refresh token when indices shift", async () => { + const originalA = createManagedAccount(0); + const originalB = createManagedAccount(1); + const liveB = { ...originalB, index: 0 }; + const liveA = { ...originalA, index: 1 }; + const snapshots = [ + [originalA, originalB], + [liveB, liveA], + ]; + let readCount = 0; + const manager = { + getAccountsSnapshot: vi.fn( + () => snapshots[Math.min(readCount++, snapshots.length - 1)], + ), + getAccountByIndex: vi.fn( + (index: number) => + [liveB, liveA].find((account) => account.index === index) ?? null, + ), + clearAuthFailures: vi.fn(), + markAccountCoolingDown: vi.fn(), + saveToDiskDebounced: vi.fn(), + } as unknown as AccountManager; + const { RefreshGuardian } = await import("../lib/refresh-guardian.js"); + const guardian = new RefreshGuardian(() => manager, { + bufferMs: 60_000, + intervalMs: 5_000, + }); + + refreshExpiringAccountsMock.mockResolvedValue( + new Map([ + [ + 1, + { + refreshed: true, + reason: "success", + tokenResult: { + type: "success", + access: "access-shifted", + refresh: "refresh-shifted", + expires: Date.now() + 3_600_000, + }, + }, + ], + ]), + ); + + await guardian.tick(); + + expect(applyRefreshResultMock).toHaveBeenCalledTimes(1); + expect(applyRefreshResultMock).toHaveBeenCalledWith( + liveB, + expect.objectContaining({ type: "success" }), + ); + expect( + manager.clearAuthFailures as ReturnType, + ).toHaveBeenCalledWith(liveB); + }); + + it("classifies failure reasons and handles no-op branches", async () => { + const accountA = createManagedAccount(0); + const accountB = createManagedAccount(1); + const accountC = createManagedAccount(2); + const accountD = createManagedAccount(3); + const accountE = createManagedAccount(4); + const manager = createManagerMock([ + accountA, + accountB, + accountC, + accountD, + accountE, + ]); + const { RefreshGuardian } = await import("../lib/refresh-guardian.js"); + const guardian = new RefreshGuardian(() => manager, { + bufferMs: 60_000, + intervalMs: 5_000, + }); + + refreshExpiringAccountsMock.mockResolvedValue( + new Map([ + [0, { refreshed: false, reason: "not_needed" }], + [1, { refreshed: false, reason: "no_refresh_token" }], + [ + 2, + { + refreshed: true, + reason: "failed", + tokenResult: { + type: "failed", + reason: "http_error", + statusCode: 429, + message: "rate limited", + }, + }, + ], + [ + 3, + { + refreshed: true, + reason: "failed", + tokenResult: { + type: "failed", + reason: "network_error", + message: "timeout", + }, + }, + ], + [ + 4, + { + refreshed: true, + reason: "failed", + tokenResult: { + type: "failed", + reason: "http_error", + statusCode: 401, + message: "expired", + }, + }, + ], + ]), + ); + + await guardian.tick(); + + expect( + manager.setAccountEnabled as ReturnType, + ).toHaveBeenCalledWith(1, false); + expect( + manager.markAccountCoolingDown as ReturnType, + ).toHaveBeenNthCalledWith(1, accountB, 60_000, "auth-failure"); + expect( + manager.markAccountCoolingDown as ReturnType, + ).toHaveBeenNthCalledWith(2, accountC, 60_000, "rate-limit"); + expect( + manager.markAccountCoolingDown as ReturnType, + ).toHaveBeenNthCalledWith(3, accountD, 60_000, "network-error"); + expect( + manager.markAccountCoolingDown as ReturnType, + ).toHaveBeenNthCalledWith(4, accountE, 60_000, "auth-failure"); + + const stats = guardian.getStats(); + expect(stats.runs).toBe(1); + expect(stats.refreshed).toBe(0); + expect(stats.failed).toBe(4); + expect(stats.notNeeded).toBe(1); + expect(stats.noRefreshToken).toBe(1); + expect(stats.rateLimited).toBe(1); + expect(stats.networkFailed).toBe(1); + expect(stats.authFailed).toBe(2); + }); + + it("covers additional failure-classification and skip branches", async () => { + const accountA = createManagedAccount(0); + const accountB = createManagedAccount(1); + const accountC = createManagedAccount(2); + const accountD = createManagedAccount(3); + const accountE = createManagedAccount(4); + const accountF = createManagedAccount(5); + const initialSnapshot = [ + accountA, + accountB, + accountC, + accountD, + accountE, + accountF, + ]; + const liveSnapshot = [accountA, accountB, accountC, accountD, accountE]; + let snapshotReads = 0; + + const manager = { + getAccountsSnapshot: vi.fn(() => { + if (snapshotReads === 0) { + snapshotReads += 1; + return initialSnapshot; + } + return liveSnapshot; + }), + getAccountByIndex: vi.fn( + (index: number) => + liveSnapshot.find((account) => account.index === index) ?? null, + ), + clearAuthFailures: vi.fn(), + markAccountCoolingDown: vi.fn(), + setAccountEnabled: vi.fn(), + saveToDiskDebounced: vi.fn(), + } as unknown as AccountManager; + const { RefreshGuardian } = await import("../lib/refresh-guardian.js"); + const guardian = new RefreshGuardian(() => manager, { + bufferMs: 60_000, + intervalMs: 5_000, + }); + + refreshExpiringAccountsMock.mockResolvedValue( + new Map([ + [ + 0, + { + refreshed: true, + reason: "failed", + }, + ], + [ + 1, + { + refreshed: true, + reason: "success", + tokenResult: { + type: "failed", + reason: "network_error", + message: "timeout", + }, + }, + ], + [ + 2, + { + refreshed: true, + reason: "failed", + tokenResult: { + type: "failed", + reason: "missing_refresh", + message: "missing refresh", + }, + }, + ], + [ + 3, + { + refreshed: true, + reason: "failed", + tokenResult: { + type: "failed", + reason: "http_error", + statusCode: 403, + message: "forbidden", + }, + }, + ], + [ + 4, + { + refreshed: true, + reason: "failed", + tokenResult: { + type: "failed", + reason: "http_error", + statusCode: 500, + message: "server error", + }, + }, + ], + [ + 5, + { + refreshed: true, + reason: "failed", + tokenResult: { + type: "failed", + reason: "network_error", + message: "unreachable account", + }, + }, + ], + [ + 999, + { + refreshed: true, + reason: "failed", + tokenResult: { + type: "failed", + reason: "network_error", + message: "unknown source account", + }, + }, + ], + ]), + ); + + await guardian.tick(); + + expect( + manager.markAccountCoolingDown as ReturnType, + ).toHaveBeenCalledTimes(5); + expect( + manager.markAccountCoolingDown as ReturnType, + ).toHaveBeenNthCalledWith(1, accountA, 60_000, "network-error"); + expect( + manager.markAccountCoolingDown as ReturnType, + ).toHaveBeenNthCalledWith(2, accountB, 60_000, "network-error"); + expect( + manager.markAccountCoolingDown as ReturnType, + ).toHaveBeenNthCalledWith(3, accountC, 60_000, "auth-failure"); + expect( + manager.markAccountCoolingDown as ReturnType, + ).toHaveBeenNthCalledWith(4, accountD, 60_000, "auth-failure"); + expect( + manager.markAccountCoolingDown as ReturnType, + ).toHaveBeenNthCalledWith(5, accountE, 60_000, "network-error"); + expect( + manager.saveToDiskDebounced as ReturnType, + ).toHaveBeenCalledTimes(1); + + const stats = guardian.getStats(); + expect(stats.runs).toBe(1); + expect(stats.refreshed).toBe(0); + expect(stats.failed).toBe(5); + expect(stats.rateLimited).toBe(0); + expect(stats.authFailed).toBe(2); + expect(stats.networkFailed).toBe(3); + }); + + it("handles thrown errors in tick and always resets running state", async () => { + const accountA = createManagedAccount(0); + const manager = createManagerMock([accountA]); + const { RefreshGuardian } = await import("../lib/refresh-guardian.js"); + const guardian = new RefreshGuardian(() => manager, { + intervalMs: 5_000, + bufferMs: 60_000, + }); + + refreshExpiringAccountsMock.mockRejectedValueOnce( + new Error("refresh failed"), + ); + await expect(guardian.tick()).resolves.toBeUndefined(); + expect(Reflect.get(guardian, "running")).toBe(false); + + refreshExpiringAccountsMock.mockRejectedValueOnce("refresh-string-failed"); + await expect(guardian.tick()).resolves.toBeUndefined(); + expect(Reflect.get(guardian, "running")).toBe(false); + }); + + it("handles account removal during tick without throwing", async () => { + const originalA = createManagedAccount(0); + const originalB = createManagedAccount(1); + const liveAfterRemoval = [{ ...originalB }]; + let snapshotReads = 0; + + const manager = { + getAccountsSnapshot: vi.fn(() => { + snapshotReads += 1; + if (snapshotReads === 1) return [originalA, originalB]; + return liveAfterRemoval; + }), + getAccountByIndex: vi.fn( + (index: number) => liveAfterRemoval[index] ?? null, + ), + clearAuthFailures: vi.fn(), + markAccountCoolingDown: vi.fn(), + setAccountEnabled: vi.fn(), + saveToDiskDebounced: vi.fn(), + } as unknown as AccountManager; + + const { RefreshGuardian } = await import("../lib/refresh-guardian.js"); + const guardian = new RefreshGuardian(() => manager, { + bufferMs: 60_000, + intervalMs: 5_000, + }); + + refreshExpiringAccountsMock.mockResolvedValue( + new Map([ + [ + 0, + { + refreshed: true, + reason: "success", + tokenResult: { + type: "success", + access: "removed-access", + refresh: "removed-refresh", + expires: Date.now() + 3_600_000, + }, + }, + ], + [ + 1, + { + refreshed: true, + reason: "failed", + tokenResult: { + type: "failed", + reason: "http_error", + statusCode: 429, + message: "rate limit", + }, + }, + ], + ]), + ); + + await expect(guardian.tick()).resolves.toBeUndefined(); + expect(applyRefreshResultMock).not.toHaveBeenCalledWith( + expect.objectContaining({ refreshToken: originalA.refreshToken }), + expect.anything(), + ); + expect( + manager.markAccountCoolingDown as ReturnType, + ).toHaveBeenCalledWith( + expect.objectContaining({ refreshToken: originalB.refreshToken }), + 60_000, + "rate-limit", + ); + }); }); - diff --git a/test/refresh-lease.test.ts b/test/refresh-lease.test.ts index b6b9d322..ef54caa5 100644 --- a/test/refresh-lease.test.ts +++ b/test/refresh-lease.test.ts @@ -7,207 +7,480 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { RefreshLeaseCoordinator } from "../lib/refresh-lease.js"; const sampleSuccessResult = { - type: "success" as const, - access: "access-token", - refresh: "refresh-token-next", - expires: Date.now() + 60_000, + type: "success" as const, + access: "access-token", + refresh: "refresh-token-next", + expires: Date.now() + 60_000, }; function hashToken(refreshToken: string): string { - return createHash("sha256").update(refreshToken).digest("hex"); + return createHash("sha256").update(refreshToken).digest("hex"); } describe("RefreshLeaseCoordinator", () => { - let leaseDir = ""; - - beforeEach(async () => { - leaseDir = await mkdtemp(join(tmpdir(), "codex-refresh-lease-")); - }); - - afterEach(() => { - leaseDir = ""; - vi.restoreAllMocks(); - }); - - it("returns owner then follower with shared result", async () => { - const coordinator = new RefreshLeaseCoordinator({ - enabled: true, - leaseDir, - leaseTtlMs: 5_000, - waitTimeoutMs: 500, - pollIntervalMs: 25, - resultTtlMs: 2_000, - }); - - const owner = await coordinator.acquire("token-a"); - expect(owner.role).toBe("owner"); - await owner.release(sampleSuccessResult); - - const follower = await coordinator.acquire("token-a"); - expect(follower.role).toBe("follower"); - expect(follower.result).toEqual(sampleSuccessResult); - }); - - it("recovers from stale lock payload", async () => { - const coordinator = new RefreshLeaseCoordinator({ - enabled: true, - leaseDir, - leaseTtlMs: 2_000, - waitTimeoutMs: 300, - pollIntervalMs: 20, - }); - - const tokenHash = "7f4a7c15f6f8c0f98d95c58f18f6f31e4f55cc4c52f8f4de4fd4d95a88e4866c"; - await mkdir(leaseDir, { recursive: true }); - await writeFile( - join(leaseDir, `${tokenHash}.lock`), - JSON.stringify({ - tokenHash, - pid: 9999, - acquiredAt: Date.now() - 10_000, - expiresAt: Date.now() - 1_000, - }), - "utf8", - ); - - const handle = await coordinator.acquire("token-stale"); - expect(handle.role).toBe("owner"); - await handle.release(sampleSuccessResult); - }); - - it("supports bypass mode", async () => { - const coordinator = new RefreshLeaseCoordinator({ - enabled: false, - leaseDir, - }); - const handle = await coordinator.acquire("token-b"); - expect(handle.role).toBe("bypass"); - await handle.release(sampleSuccessResult); - }); - - it("does not delete unreadable lock payloads", async () => { - const refreshToken = "token-partial"; - const coordinator = new RefreshLeaseCoordinator({ - enabled: true, - leaseDir, - leaseTtlMs: 10_000, - waitTimeoutMs: 120, - pollIntervalMs: 25, - resultTtlMs: 2_000, - }); - await mkdir(leaseDir, { recursive: true }); - const tokenHash = hashToken(refreshToken); - const lockPath = join(leaseDir, `${tokenHash}.lock`); - await writeFile(lockPath, "{", "utf8"); - - const handle = await coordinator.acquire(refreshToken); - expect(handle.role).toBe("bypass"); - const lockContent = await readFile(lockPath, "utf8"); - expect(lockContent).toBe("{"); - }); - - it("retries stale lock cleanup when unlink is temporarily busy", async () => { - const refreshToken = "token-retry"; - const tokenHash = hashToken(refreshToken); - let busyCount = 0; - const originalUnlink = fsPromises.unlink.bind(fsPromises); - const fsOps = { - mkdir: fsPromises.mkdir.bind(fsPromises), - open: fsPromises.open.bind(fsPromises), - writeFile: fsPromises.writeFile.bind(fsPromises), - rename: fsPromises.rename.bind(fsPromises), - unlink: vi.fn(async (path: Parameters[0]) => { - if (String(path).endsWith(".lock") && busyCount < 2) { - busyCount += 1; - const error = new Error("busy") as NodeJS.ErrnoException; - error.code = "EBUSY"; - throw error; - } - return originalUnlink(path); - }), - readFile: fsPromises.readFile.bind(fsPromises), - stat: fsPromises.stat.bind(fsPromises), - readdir: fsPromises.readdir.bind(fsPromises), - }; - - const coordinator = new RefreshLeaseCoordinator({ - enabled: true, - leaseDir, - leaseTtlMs: 2_000, - waitTimeoutMs: 400, - pollIntervalMs: 20, - resultTtlMs: 2_000, - fsOps, - }); - - await mkdir(leaseDir, { recursive: true }); - const lockPath = join(leaseDir, `${tokenHash}.lock`); - await writeFile( - lockPath, - JSON.stringify({ - tokenHash, - pid: 1111, - acquiredAt: Date.now() - 10_000, - expiresAt: Date.now() - 5_000, - }), - "utf8", - ); - - const handle = await coordinator.acquire(refreshToken); - expect(handle.role).toBe("owner"); - expect(fsOps.unlink).toHaveBeenCalled(); - expect(busyCount).toBe(2); - await handle.release(sampleSuccessResult); - }); - - it("times out to bypass when stale lock cannot be deleted", async () => { - const refreshToken = "token-timeout"; - const tokenHash = hashToken(refreshToken); - const originalUnlink = fsPromises.unlink.bind(fsPromises); - const fsOps = { - mkdir: fsPromises.mkdir.bind(fsPromises), - open: fsPromises.open.bind(fsPromises), - writeFile: fsPromises.writeFile.bind(fsPromises), - rename: fsPromises.rename.bind(fsPromises), - unlink: vi.fn(async (path: Parameters[0]) => { - if (String(path).endsWith(".lock")) { - const error = new Error("busy") as NodeJS.ErrnoException; - error.code = "EBUSY"; - throw error; - } - return originalUnlink(path); - }), - readFile: fsPromises.readFile.bind(fsPromises), - stat: fsPromises.stat.bind(fsPromises), - readdir: fsPromises.readdir.bind(fsPromises), - }; - - const coordinator = new RefreshLeaseCoordinator({ - enabled: true, - leaseDir, - leaseTtlMs: 2_000, - waitTimeoutMs: 140, - pollIntervalMs: 25, - resultTtlMs: 2_000, - fsOps, - }); - - await mkdir(leaseDir, { recursive: true }); - const lockPath = join(leaseDir, `${tokenHash}.lock`); - await writeFile( - lockPath, - JSON.stringify({ - tokenHash, - pid: 2222, - acquiredAt: Date.now() - 10_000, - expiresAt: Date.now() - 5_000, - }), - "utf8", - ); - - const handle = await coordinator.acquire(refreshToken); - expect(handle.role).toBe("bypass"); - expect(fsOps.unlink).toHaveBeenCalled(); - await handle.release(sampleSuccessResult); - }); + let leaseDir = ""; + + beforeEach(async () => { + leaseDir = await mkdtemp(join(tmpdir(), "codex-refresh-lease-")); + }); + + afterEach(() => { + leaseDir = ""; + vi.restoreAllMocks(); + }); + + it("returns owner then follower with shared result", async () => { + const coordinator = new RefreshLeaseCoordinator({ + enabled: true, + leaseDir, + leaseTtlMs: 5_000, + waitTimeoutMs: 500, + pollIntervalMs: 25, + resultTtlMs: 2_000, + }); + + const owner = await coordinator.acquire("token-a"); + expect(owner.role).toBe("owner"); + await owner.release(sampleSuccessResult); + + const follower = await coordinator.acquire("token-a"); + expect(follower.role).toBe("follower"); + expect(follower.result).toEqual(sampleSuccessResult); + }); + + it("recovers from stale lock payload", async () => { + const coordinator = new RefreshLeaseCoordinator({ + enabled: true, + leaseDir, + leaseTtlMs: 2_000, + waitTimeoutMs: 300, + pollIntervalMs: 20, + }); + + const tokenHash = + "7f4a7c15f6f8c0f98d95c58f18f6f31e4f55cc4c52f8f4de4fd4d95a88e4866c"; + await mkdir(leaseDir, { recursive: true }); + await writeFile( + join(leaseDir, `${tokenHash}.lock`), + JSON.stringify({ + tokenHash, + pid: 9999, + acquiredAt: Date.now() - 10_000, + expiresAt: Date.now() - 1_000, + }), + "utf8", + ); + + const handle = await coordinator.acquire("token-stale"); + expect(handle.role).toBe("owner"); + await handle.release(sampleSuccessResult); + }); + + it("supports bypass mode", async () => { + const coordinator = new RefreshLeaseCoordinator({ + enabled: false, + leaseDir, + }); + const handle = await coordinator.acquire("token-b"); + expect(handle.role).toBe("bypass"); + await handle.release(sampleSuccessResult); + }); + + it("does not delete unreadable lock payloads", async () => { + const refreshToken = "token-partial"; + const coordinator = new RefreshLeaseCoordinator({ + enabled: true, + leaseDir, + leaseTtlMs: 10_000, + waitTimeoutMs: 120, + pollIntervalMs: 25, + resultTtlMs: 2_000, + }); + await mkdir(leaseDir, { recursive: true }); + const tokenHash = hashToken(refreshToken); + const lockPath = join(leaseDir, `${tokenHash}.lock`); + await writeFile(lockPath, "{", "utf8"); + + const handle = await coordinator.acquire(refreshToken); + expect(handle.role).toBe("bypass"); + const lockContent = await readFile(lockPath, "utf8"); + expect(lockContent).toBe("{"); + }); + + it("retries stale lock cleanup when unlink is temporarily busy", async () => { + const refreshToken = "token-retry"; + const tokenHash = hashToken(refreshToken); + let busyCount = 0; + const originalUnlink = fsPromises.unlink.bind(fsPromises); + const fsOps = { + mkdir: fsPromises.mkdir.bind(fsPromises), + open: fsPromises.open.bind(fsPromises), + writeFile: fsPromises.writeFile.bind(fsPromises), + rename: fsPromises.rename.bind(fsPromises), + unlink: vi.fn(async (path: Parameters[0]) => { + if (String(path).endsWith(".lock") && busyCount < 2) { + busyCount += 1; + const error = new Error("busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return originalUnlink(path); + }), + readFile: fsPromises.readFile.bind(fsPromises), + stat: fsPromises.stat.bind(fsPromises), + readdir: fsPromises.readdir.bind(fsPromises), + }; + + const coordinator = new RefreshLeaseCoordinator({ + enabled: true, + leaseDir, + leaseTtlMs: 2_000, + waitTimeoutMs: 400, + pollIntervalMs: 20, + resultTtlMs: 2_000, + fsOps, + }); + + await mkdir(leaseDir, { recursive: true }); + const lockPath = join(leaseDir, `${tokenHash}.lock`); + await writeFile( + lockPath, + JSON.stringify({ + tokenHash, + pid: 1111, + acquiredAt: Date.now() - 10_000, + expiresAt: Date.now() - 5_000, + }), + "utf8", + ); + + const handle = await coordinator.acquire(refreshToken); + expect(handle.role).toBe("owner"); + expect(fsOps.unlink).toHaveBeenCalled(); + expect(busyCount).toBe(2); + await handle.release(sampleSuccessResult); + }); + + it("times out to bypass when stale lock cannot be deleted", async () => { + const refreshToken = "token-timeout"; + const tokenHash = hashToken(refreshToken); + const originalUnlink = fsPromises.unlink.bind(fsPromises); + const fsOps = { + mkdir: fsPromises.mkdir.bind(fsPromises), + open: fsPromises.open.bind(fsPromises), + writeFile: fsPromises.writeFile.bind(fsPromises), + rename: fsPromises.rename.bind(fsPromises), + unlink: vi.fn(async (path: Parameters[0]) => { + if (String(path).endsWith(".lock")) { + const error = new Error("busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return originalUnlink(path); + }), + readFile: fsPromises.readFile.bind(fsPromises), + stat: fsPromises.stat.bind(fsPromises), + readdir: fsPromises.readdir.bind(fsPromises), + }; + + const coordinator = new RefreshLeaseCoordinator({ + enabled: true, + leaseDir, + leaseTtlMs: 2_000, + waitTimeoutMs: 140, + pollIntervalMs: 25, + resultTtlMs: 2_000, + fsOps, + }); + + await mkdir(leaseDir, { recursive: true }); + const lockPath = join(leaseDir, `${tokenHash}.lock`); + await writeFile( + lockPath, + JSON.stringify({ + tokenHash, + pid: 2222, + acquiredAt: Date.now() - 10_000, + expiresAt: Date.now() - 5_000, + }), + "utf8", + ); + + const handle = await coordinator.acquire(refreshToken); + expect(handle.role).toBe("bypass"); + expect(fsOps.unlink).toHaveBeenCalled(); + await handle.release(sampleSuccessResult); + }); + it("treats empty refresh token as bypass", async () => { + const coordinator = new RefreshLeaseCoordinator({ + enabled: true, + leaseDir, + }); + + const handle = await coordinator.acquire(" "); + expect(handle.role).toBe("bypass"); + await handle.release(sampleSuccessResult); + }); + + it("parses environment toggle values in fromEnvironment", async () => { + const originalEnv = { + VITEST: process.env.VITEST, + NODE_ENV: process.env.NODE_ENV, + CODEX_AUTH_REFRESH_LEASE: process.env.CODEX_AUTH_REFRESH_LEASE, + CODEX_AUTH_REFRESH_LEASE_DIR: process.env.CODEX_AUTH_REFRESH_LEASE_DIR, + CODEX_AUTH_REFRESH_LEASE_TTL_MS: + process.env.CODEX_AUTH_REFRESH_LEASE_TTL_MS, + CODEX_AUTH_REFRESH_LEASE_WAIT_MS: + process.env.CODEX_AUTH_REFRESH_LEASE_WAIT_MS, + CODEX_AUTH_REFRESH_LEASE_POLL_MS: + process.env.CODEX_AUTH_REFRESH_LEASE_POLL_MS, + CODEX_AUTH_REFRESH_LEASE_RESULT_TTL_MS: + process.env.CODEX_AUTH_REFRESH_LEASE_RESULT_TTL_MS, + }; + + try { + process.env.VITEST = "false"; + process.env.NODE_ENV = "production"; + process.env.CODEX_AUTH_REFRESH_LEASE = "yes"; + process.env.CODEX_AUTH_REFRESH_LEASE_DIR = `${leaseDir}\n`; + process.env.CODEX_AUTH_REFRESH_LEASE_TTL_MS = "2500"; + process.env.CODEX_AUTH_REFRESH_LEASE_WAIT_MS = "900"; + process.env.CODEX_AUTH_REFRESH_LEASE_POLL_MS = "60"; + process.env.CODEX_AUTH_REFRESH_LEASE_RESULT_TTL_MS = "2500"; + + const enabledCoordinator = RefreshLeaseCoordinator.fromEnvironment(); + const enabledHandle = + await enabledCoordinator.acquire("token-env-enabled"); + expect(enabledHandle.role).toBe("owner"); + await enabledHandle.release(sampleSuccessResult); + + process.env.VITEST = "true"; + process.env.NODE_ENV = "test"; + process.env.CODEX_AUTH_REFRESH_LEASE = "maybe"; + const invalidValueCoordinator = RefreshLeaseCoordinator.fromEnvironment(); + const invalidHandle = + await invalidValueCoordinator.acquire("token-env-invalid"); + expect(invalidHandle.role).toBe("bypass"); + + process.env.CODEX_AUTH_REFRESH_LEASE = "no"; + const disabledCoordinator = RefreshLeaseCoordinator.fromEnvironment(); + const disabledHandle = + await disabledCoordinator.acquire("token-env-disabled"); + expect(disabledHandle.role).toBe("bypass"); + } finally { + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } + }); + + it("bypasses when lock open fails with non-EEXIST error", async () => { + const fsOps = { + mkdir: fsPromises.mkdir.bind(fsPromises), + open: vi.fn(async () => { + const error = new Error("permission") as NodeJS.ErrnoException; + error.code = "EACCES"; + throw error; + }), + writeFile: fsPromises.writeFile.bind(fsPromises), + rename: fsPromises.rename.bind(fsPromises), + unlink: fsPromises.unlink.bind(fsPromises), + readFile: fsPromises.readFile.bind(fsPromises), + stat: fsPromises.stat.bind(fsPromises), + readdir: fsPromises.readdir.bind(fsPromises), + }; + const coordinator = new RefreshLeaseCoordinator({ + enabled: true, + leaseDir, + waitTimeoutMs: 80, + pollIntervalMs: 20, + fsOps, + }); + + const handle = await coordinator.acquire("token-open-error"); + expect(handle.role).toBe("bypass"); + expect(fsOps.open).toHaveBeenCalled(); + }); + + it("ignores mismatched result payload and stale results", async () => { + const refreshToken = "token-result-paths"; + const tokenHash = hashToken(refreshToken); + const resultPath = join(leaseDir, `${tokenHash}.result.json`); + await mkdir(leaseDir, { recursive: true }); + await writeFile( + resultPath, + JSON.stringify({ + tokenHash: "different-hash", + createdAt: Date.now(), + result: sampleSuccessResult, + }), + "utf8", + ); + + let coordinator = new RefreshLeaseCoordinator({ + enabled: true, + leaseDir, + waitTimeoutMs: 120, + pollIntervalMs: 20, + resultTtlMs: 2_000, + }); + let handle = await coordinator.acquire(refreshToken); + expect(handle.role).toBe("owner"); + await handle.release(); + + await writeFile( + resultPath, + JSON.stringify({ + tokenHash, + createdAt: Date.now() - 10_000, + result: sampleSuccessResult, + }), + "utf8", + ); + + coordinator = new RefreshLeaseCoordinator({ + enabled: true, + leaseDir, + waitTimeoutMs: 120, + pollIntervalMs: 20, + resultTtlMs: 500, + }); + handle = await coordinator.acquire(refreshToken); + expect(handle.role).toBe("owner"); + await handle.release(); + await expect(fsPromises.stat(resultPath)).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + + it("handles lock staleness edge cases from stat and payload validation", async () => { + const refreshToken = "token-staleness-cases"; + const tokenHash = hashToken(refreshToken); + const lockPath = join(leaseDir, `${tokenHash}.lock`); + await mkdir(leaseDir, { recursive: true }); + + await writeFile( + lockPath, + JSON.stringify({ tokenHash, pid: "bad" }), + "utf8", + ); + let coordinator = new RefreshLeaseCoordinator({ + enabled: true, + leaseDir, + leaseTtlMs: 2_000, + waitTimeoutMs: 120, + pollIntervalMs: 20, + }); + let handle = await coordinator.acquire(refreshToken); + expect(handle.role).toBe("bypass"); + + await writeFile( + lockPath, + JSON.stringify({ + tokenHash, + pid: process.pid, + acquiredAt: Date.now(), + expiresAt: Date.now() + 5_000, + }), + "utf8", + ); + await fsPromises.utimes( + lockPath, + new Date(Date.now() - 10_000), + new Date(Date.now() - 10_000), + ); + coordinator = new RefreshLeaseCoordinator({ + enabled: true, + leaseDir, + leaseTtlMs: 1_000, + waitTimeoutMs: 160, + pollIntervalMs: 20, + }); + handle = await coordinator.acquire(refreshToken); + expect(handle.role).toBe("owner"); + await handle.release(sampleSuccessResult); + await fsPromises.rm(join(leaseDir, `${tokenHash}.result.json`), { + force: true, + }); + + await writeFile( + lockPath, + JSON.stringify({ + tokenHash, + pid: process.pid, + acquiredAt: Date.now(), + expiresAt: Date.now() + 5_000, + }), + "utf8", + ); + const fsOps = { + mkdir: fsPromises.mkdir.bind(fsPromises), + open: fsPromises.open.bind(fsPromises), + writeFile: fsPromises.writeFile.bind(fsPromises), + rename: fsPromises.rename.bind(fsPromises), + unlink: fsPromises.unlink.bind(fsPromises), + readFile: fsPromises.readFile.bind(fsPromises), + stat: vi.fn(async (...args: Parameters) => { + if (String(args[0]) === lockPath) { + const error = new Error("access denied") as NodeJS.ErrnoException; + error.code = "EACCES"; + throw error; + } + return fsPromises.stat(...args); + }), + readdir: fsPromises.readdir.bind(fsPromises), + }; + + coordinator = new RefreshLeaseCoordinator({ + enabled: true, + leaseDir, + leaseTtlMs: 5_000, + waitTimeoutMs: 120, + pollIntervalMs: 20, + fsOps, + }); + handle = await coordinator.acquire(refreshToken); + expect(handle.role).toBe("bypass"); + }); + + it("prunes stale artifacts while keeping non-file entries", async () => { + await mkdir(leaseDir, { recursive: true }); + const staleLock = join(leaseDir, "stale.lock"); + const staleResult = join(leaseDir, "stale.result.json"); + const liveOther = join(leaseDir, "ignore.txt"); + const nestedDir = join(leaseDir, "nested-dir"); + await writeFile(staleLock, "{}", "utf8"); + await writeFile(staleResult, "{}", "utf8"); + await writeFile(liveOther, "keep", "utf8"); + await mkdir(nestedDir, { recursive: true }); + const oldTime = new Date(Date.now() - 60_000); + await fsPromises.utimes(staleLock, oldTime, oldTime); + await fsPromises.utimes(staleResult, oldTime, oldTime); + + const coordinator = new RefreshLeaseCoordinator({ + enabled: true, + leaseDir, + leaseTtlMs: 2_000, + resultTtlMs: 2_000, + }); + const privateApi = coordinator as unknown as { + pruneExpiredArtifacts: () => Promise; + }; + await privateApi.pruneExpiredArtifacts(); + + await expect(fsPromises.stat(staleLock)).rejects.toMatchObject({ + code: "ENOENT", + }); + await expect(fsPromises.stat(staleResult)).rejects.toMatchObject({ + code: "ENOENT", + }); + await expect(fsPromises.readFile(liveOther, "utf8")).resolves.toBe("keep"); + await expect(fsPromises.stat(nestedDir)).resolves.toMatchObject({ + isDirectory: expect.any(Function), + }); + }); }); diff --git a/test/refresh-queue.test.ts b/test/refresh-queue.test.ts index 620f04b8..90982044 100644 --- a/test/refresh-queue.test.ts +++ b/test/refresh-queue.test.ts @@ -6,16 +6,23 @@ import { RefreshQueue, getRefreshQueue, resetRefreshQueue, queuedRefresh } from import * as authModule from "../lib/auth/auth.js"; import { RefreshLeaseCoordinator } from "../lib/refresh-lease.js"; +const loggerMocks = vi.hoisted(() => ({ + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +})); + vi.mock("../lib/auth/auth.js", () => ({ refreshAccessToken: vi.fn(), })); vi.mock("../lib/logger.js", () => ({ createLogger: () => ({ - info: vi.fn(), - debug: vi.fn(), - warn: vi.fn(), - error: vi.fn(), + info: loggerMocks.info, + debug: loggerMocks.debug, + warn: loggerMocks.warn, + error: loggerMocks.error, }), })); @@ -44,7 +51,10 @@ describe("RefreshQueue", () => { expect(result).toEqual(mockResult); expect(authModule.refreshAccessToken).toHaveBeenCalledTimes(1); - expect(authModule.refreshAccessToken).toHaveBeenCalledWith("test-refresh-token"); + expect(authModule.refreshAccessToken).toHaveBeenCalledWith( + "test-refresh-token", + expect.any(Object), + ); }); it("should return failed result when refresh fails", async () => { @@ -76,6 +86,21 @@ describe("RefreshQueue", () => { expect(result.message).toBe("Network timeout"); } }); + + it("should classify AbortError exceptions as non-network failures", async () => { + vi.mocked(authModule.refreshAccessToken).mockRejectedValue( + Object.assign(new Error("Request aborted"), { name: "AbortError" }), + ); + + const queue = new RefreshQueue(); + const result = await queue.refresh("abort-token"); + + expect(result.type).toBe("failed"); + if (result.type === "failed") { + expect(result.reason).toBe("unknown"); + expect(result.message).toBe("Request aborted"); + } + }); }); describe("deduplication of concurrent requests", () => { @@ -130,9 +155,9 @@ describe("RefreshQueue", () => { ]); expect(authModule.refreshAccessToken).toHaveBeenCalledTimes(3); - expect(authModule.refreshAccessToken).toHaveBeenCalledWith("token-1"); - expect(authModule.refreshAccessToken).toHaveBeenCalledWith("token-2"); - expect(authModule.refreshAccessToken).toHaveBeenCalledWith("token-3"); + expect(authModule.refreshAccessToken).toHaveBeenCalledWith("token-1", expect.any(Object)); + expect(authModule.refreshAccessToken).toHaveBeenCalledWith("token-2", expect.any(Object)); + expect(authModule.refreshAccessToken).toHaveBeenCalledWith("token-3", expect.any(Object)); }); it("should allow new refresh after previous completes", async () => { @@ -217,32 +242,239 @@ describe("RefreshQueue", () => { }); describe("stale entry cleanup", () => { - it("should clean up stale entries after maxEntryAge", async () => { + it("evicts stale acquire-stage entries and allows a fresh retry", async () => { vi.useFakeTimers(); - - let resolveRefresh: () => void; - const stuckPromise = new Promise(() => {}); - vi.mocked(authModule.refreshAccessToken) - .mockReturnValueOnce(stuckPromise) - .mockResolvedValue({ - type: "success", + try { + const leaseAcquire = vi + .fn() + .mockImplementationOnce( + () => + new Promise>>(() => {}), + ) + .mockResolvedValue({ + role: "owner" as const, + release: vi.fn().mockResolvedValue(undefined), + }); + const leaseCoordinator = { acquire: leaseAcquire } as unknown as RefreshLeaseCoordinator; + const successResult = { + type: "success" as const, + access: "access", + refresh: "refresh", + expires: Date.now() + 3600_000, + }; + vi.mocked(authModule.refreshAccessToken).mockResolvedValue(successResult); + + const queue = new RefreshQueue(1000, leaseCoordinator); + const firstAttempt = queue.refresh("stale-acquire-token"); + void firstAttempt; + await Promise.resolve(); + expect(queue.pendingCount).toBe(1); + + await vi.advanceTimersByTimeAsync(1200); + + const secondResult = await queue.refresh("stale-acquire-token"); + expect(secondResult).toEqual(successResult); + expect(leaseAcquire).toHaveBeenCalledTimes(2); + expect(queue.pendingCount).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + it("joins a superseding generation after stale acquire eviction", async () => { + vi.useFakeTimers(); + try { + const ownerLease = { + role: "owner" as const, + release: vi.fn().mockResolvedValue(undefined), + }; + let resolveFirstAcquire: + | ((value: Awaited>) => void) + | undefined; + const firstAcquire = new Promise>>( + (resolve) => { + resolveFirstAcquire = resolve; + }, + ); + const leaseAcquire = vi.fn().mockReturnValueOnce(firstAcquire).mockResolvedValue(ownerLease); + const leaseCoordinator = { acquire: leaseAcquire } as unknown as RefreshLeaseCoordinator; + const successResult = { + type: "success" as const, + access: "access-after-supersede", + refresh: "refresh-after-supersede", + expires: Date.now() + 3600_000, + }; + let resolveRefresh: ((value: typeof successResult) => void) | undefined; + const delayedRefresh = new Promise((resolve) => { + resolveRefresh = resolve; + }); + vi.mocked(authModule.refreshAccessToken).mockReturnValue(delayedRefresh); + + const queue = new RefreshQueue(1000, leaseCoordinator); + const firstAttempt = queue.refresh("superseded-acquire-token"); + await Promise.resolve(); + expect(queue.pendingCount).toBe(1); + + await vi.advanceTimersByTimeAsync(1200); + const secondAttempt = queue.refresh("superseded-acquire-token"); + await Promise.resolve(); + expect(leaseAcquire).toHaveBeenCalledTimes(2); + + resolveFirstAcquire?.(ownerLease); + await Promise.resolve(); + expect(vi.mocked(authModule.refreshAccessToken)).toHaveBeenCalledTimes(1); + + resolveRefresh?.(successResult); + const [firstResult, secondResult] = await Promise.all([firstAttempt, secondAttempt]); + expect(firstResult).toEqual(successResult); + expect(secondResult).toEqual(successResult); + expect(queue.pendingCount).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + it("times out stale unresolved entries and allows retry", async () => { + vi.useFakeTimers(); + try { + const stuckPromise = new Promise(() => {}); + const successfulRefresh = { + type: "success" as const, access: "access", - refresh: "refresh", + refresh: "refresh", expires: Date.now() + 3600000, + }; + vi.mocked(authModule.refreshAccessToken) + .mockReturnValueOnce(stuckPromise) + .mockResolvedValueOnce(successfulRefresh); + + const queue = new RefreshQueue(1000); + + const firstAttempt = queue.refresh("stuck-token"); + expect(queue.pendingCount).toBe(1); + + await vi.advanceTimersByTimeAsync(1500); + const firstResult = await firstAttempt; + expect(firstResult.type).toBe("failed"); + if (firstResult.type === "failed") { + expect(firstResult.reason).toBe("unknown"); + expect(firstResult.message).toContain("Refresh timeout after"); + } + expect(queue.pendingCount).toBe(0); + + const secondResult = await queue.refresh("stuck-token"); + expect(secondResult).toEqual(successfulRefresh); + expect(vi.mocked(authModule.refreshAccessToken)).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it("recovers after a 429 response and retries cleanly", async () => { + vi.useFakeTimers(); + try { + const rateLimitedResult = { + type: "failed" as const, + reason: "http_error" as const, + statusCode: 429, + message: "Rate limited", + }; + const successfulRefresh = { + type: "success" as const, + access: "access-after-429", + refresh: "refresh-after-429", + expires: Date.now() + 3600000, + }; + vi.mocked(authModule.refreshAccessToken) + .mockResolvedValueOnce(rateLimitedResult) + .mockResolvedValueOnce(successfulRefresh); + + const queue = new RefreshQueue(1000); + const firstResult = await queue.refresh("rate-limited-token"); + expect(firstResult).toEqual(rateLimitedResult); + expect(queue.pendingCount).toBe(0); + + const secondResult = await queue.refresh("rate-limited-token"); + expect(secondResult).toEqual(successfulRefresh); + expect(vi.mocked(authModule.refreshAccessToken)).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it("keeps dedupe for same token before timeout elapses", async () => { + vi.useFakeTimers(); + try { + let resolveRefresh: + | ((value: { type: "success"; access: string; refresh: string; expires: number }) => void) + | undefined; + const inFlight = new Promise<{ + type: "success"; + access: string; + refresh: string; + expires: number; + }>((resolve) => { + resolveRefresh = resolve; }); + vi.mocked(authModule.refreshAccessToken).mockReturnValueOnce(inFlight as Promise); + + const queue = new RefreshQueue(1000); + const p1 = queue.refresh("same-token"); + const p2 = queue.refresh("same-token"); + await Promise.resolve(); + + expect(vi.mocked(authModule.refreshAccessToken)).toHaveBeenCalledTimes(1); + expect(queue.pendingCount).toBe(1); + + resolveRefresh?.({ + type: "success", + access: "a", + refresh: "r", + expires: Date.now() + 3600_000, + }); + + await vi.advanceTimersByTimeAsync(10); + const [r1, r2] = await Promise.all([p1, p2]); + expect(r1).toEqual(r2); + expect(queue.pendingCount).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + it("logs stale refresh-stage warnings only once per entry", async () => { + vi.mocked(authModule.refreshAccessToken).mockResolvedValue({ + type: "success", + access: "a", + refresh: "r", + expires: Date.now() + 3600_000, + }); const queue = new RefreshQueue(1000); - - queue.refresh("stuck-token"); - expect(queue.pendingCount).toBe(1); - - vi.advanceTimersByTime(1500); - - await queue.refresh("other-token"); - - expect(queue.pendingCount).toBe(0); - - vi.useRealTimers(); + const queueInternal = queue as unknown as { + pending: Map; + startedAt: number; + stage: "acquire" | "refresh"; + generation: number; + staleWarningLogged?: boolean; + }>; + }; + queueInternal.pending.set("stale-refresh-token", { + promise: new Promise(() => {}), + startedAt: Date.now() - 5_000, + stage: "refresh", + generation: 1, + }); + + await queue.refresh("fresh-token-1"); + await queue.refresh("fresh-token-2"); + + const staleWarnCalls = loggerMocks.warn.mock.calls.filter((call) => + String(call[0]).includes("stale warning threshold"), + ); + expect(staleWarnCalls).toHaveLength(1); + queue.clear(); }); }); @@ -272,7 +504,10 @@ describe("RefreshQueue", () => { const result = await queuedRefresh("test-token"); expect(result).toEqual(mockResult); - expect(authModule.refreshAccessToken).toHaveBeenCalledWith("test-token"); + expect(authModule.refreshAccessToken).toHaveBeenCalledWith( + "test-token", + expect.any(Object), + ); }); }); @@ -563,11 +798,14 @@ describe("RefreshQueue", () => { queueInternal.tokenRotationMap.set("unrelated-token", "some-other-token"); queueInternal.tokenRotationMap.set("original-token", "rotated-token"); - const promise2 = queue.refresh("rotated-token"); - await Promise.resolve(); - - expect(authModule.refreshAccessToken).toHaveBeenCalledTimes(1); - expect(authModule.refreshAccessToken).toHaveBeenCalledWith("original-token"); + const promise2 = queue.refresh("rotated-token"); + await Promise.resolve(); + + expect(authModule.refreshAccessToken).toHaveBeenCalledTimes(1); + expect(authModule.refreshAccessToken).toHaveBeenCalledWith( + "original-token", + expect.any(Object), + ); outerResolve!({ type: "success", @@ -610,16 +848,22 @@ describe("RefreshQueue", () => { expires: Date.now() + 3600000, }; let releaseRefresh: (() => void) | null = null; + let signalReleaseAssigned: (() => void) | null = null; + const releaseAssigned = new Promise((resolve) => { + signalReleaseAssigned = resolve; + }); vi.mocked(authModule.refreshAccessToken).mockImplementation(() => { return new Promise((resolve) => { releaseRefresh = () => resolve(delayedResult); + signalReleaseAssigned?.(); + signalReleaseAssigned = null; }); }); const ownerRefresh = queueA.refresh("same-cross-token"); - await new Promise((resolve) => setTimeout(resolve, 50)); + await releaseAssigned; const followerRefresh = queueB.refresh("same-cross-token"); - + expect(releaseRefresh).not.toBeNull(); releaseRefresh?.(); const [ownerResult, followerResult] = await Promise.all([ @@ -628,9 +872,9 @@ describe("RefreshQueue", () => { ]); expect(ownerResult).toEqual(delayedResult); expect(followerResult).toEqual(delayedResult); - expect(authModule.refreshAccessToken).toHaveBeenCalledTimes(1); - }); - }); + expect(authModule.refreshAccessToken).toHaveBeenCalledTimes(1); + }); + }); describe("lease failure handling", () => { it("falls back to local refresh when lease acquisition throws", async () => { diff --git a/test/request-transformer.test.ts b/test/request-transformer.test.ts index 0dc0ef85..b53ca89b 100644 --- a/test/request-transformer.test.ts +++ b/test/request-transformer.test.ts @@ -369,21 +369,43 @@ describe('Request Transformer Module', () => { expect(result![2].content).toBe('3'); }); - it('should handle custom ID formats (future-proof)', async () => { - const input: InputItem[] = [ - { id: 'custom_id_format', type: 'message', role: 'user', content: 'test' }, - { id: 'another-format-123', type: 'message', role: 'user', content: 'test2' }, - ]; - const result = filterInput(input); + it('should handle custom ID formats (future-proof)', async () => { + const input: InputItem[] = [ + { id: 'custom_id_format', type: 'message', role: 'user', content: 'test' }, + { id: 'another-format-123', type: 'message', role: 'user', content: 'test2' }, + ]; + const result = filterInput(input); + + expect(result).toHaveLength(2); + expect(result![0]).not.toHaveProperty('id'); + expect(result![1]).not.toHaveProperty('id'); + }); - expect(result).toHaveLength(2); - expect(result![0]).not.toHaveProperty('id'); - expect(result![1]).not.toHaveProperty('id'); - }); + it('should skip sparse entries without throwing', async () => { + const sparse = new Array(3); + sparse[0] = { id: 'msg_1', type: 'message', role: 'user', content: 'test' }; + sparse[2] = { id: 'msg_2', type: 'message', role: 'assistant', content: 'reply' }; - it('should return undefined for undefined input', async () => { - expect(filterInput(undefined)).toBeUndefined(); - }); + const result = filterInput(sparse as InputItem[]); + + expect(result).toHaveLength(2); + expect(result![0]).not.toHaveProperty('id'); + expect(result![1]).not.toHaveProperty('id'); + }); + + it('should return undefined for undefined input', async () => { + expect(filterInput(undefined)).toBeUndefined(); + }); + + it('should remove empty-string id values', async () => { + const input: InputItem[] = [ + { id: '', type: 'message', role: 'user', content: 'hello' }, + ]; + const result = filterInput(input); + + expect(result).toHaveLength(1); + expect(result![0]).not.toHaveProperty('id'); + }); it('should return non-array input as-is', async () => { const notArray = { notAnArray: true }; @@ -2286,6 +2308,59 @@ describe('Request Transformer Module', () => { expect(params.$schema).toBeUndefined(); expect(params.properties.prop.const).toBeUndefined(); }); + + it('supports named-parameter options form', async () => { + const baseBody: RequestBody = { + model: 'gpt-5-codex-low', + input: [ + { type: 'message', role: 'user', content: 'hello' }, + ], + tools: [ + { + type: 'function', + function: { + name: 'echo', + parameters: { + type: 'object', + properties: { + value: { type: 'string' }, + }, + }, + }, + }, + ] as any, + }; + + const positional = await transformRequestBody( + JSON.parse(JSON.stringify(baseBody)), + codexInstructions, + { global: {}, models: {} }, + true, + true, + 'always', + 12, + ); + const named = await transformRequestBody({ + body: JSON.parse(JSON.stringify(baseBody)), + codexInstructions, + userConfig: { global: {}, models: {} }, + codexMode: true, + fastSession: true, + fastSessionStrategy: 'always', + fastSessionMaxInputItems: 12, + }); + + expect(named).toEqual(positional); + }); + + it('throws clear TypeError when named-parameter body is invalid', async () => { + await expect( + transformRequestBody({ + body: null as unknown as RequestBody, + codexInstructions, + }), + ).rejects.toThrowError('transformRequestBody requires body'); + }); }); }); }); diff --git a/test/rotation.test.ts b/test/rotation.test.ts index d8bdb1fa..f246bf70 100644 --- a/test/rotation.test.ts +++ b/test/rotation.test.ts @@ -402,17 +402,19 @@ describe("selectHybridAccount", () => { it("pidOffsetEnabled uses process.pid modulo 100 for offset calculation", () => { const originalPid = process.pid; - Object.defineProperty(process, 'pid', { value: 50, configurable: true }); + try { + Object.defineProperty(process, "pid", { value: 50, configurable: true }); - const accounts: AccountWithMetrics[] = [ - { index: 0, isAvailable: true, lastUsed: Date.now() }, - { index: 1, isAvailable: true, lastUsed: Date.now() }, - ]; + const accounts: AccountWithMetrics[] = [ + { index: 0, isAvailable: true, lastUsed: Date.now() }, + { index: 1, isAvailable: true, lastUsed: Date.now() }, + ]; - const result = selectHybridAccount(accounts, healthTracker, tokenTracker, undefined, undefined, { pidOffsetEnabled: true }); - expect(result).not.toBe(null); - - Object.defineProperty(process, 'pid', { value: originalPid, configurable: true }); + const result = selectHybridAccount(accounts, healthTracker, tokenTracker, undefined, undefined, { pidOffsetEnabled: true }); + expect(result).not.toBe(null); + } finally { + Object.defineProperty(process, "pid", { value: originalPid, configurable: true }); + } }); it("pidOffsetEnabled differentiates selection across different PIDs", () => { @@ -426,16 +428,18 @@ describe("selectHybridAccount", () => { ]; const selectedIndices = new Set(); - for (let pid = 0; pid < 100; pid += 10) { - Object.defineProperty(process, 'pid', { value: pid, configurable: true }); - const result = selectHybridAccount(accounts, healthTracker, tokenTracker, undefined, undefined, { pidOffsetEnabled: true }); - if (result) { - selectedIndices.add(result.index); + try { + for (let pid = 0; pid < 100; pid += 10) { + Object.defineProperty(process, "pid", { value: pid, configurable: true }); + const result = selectHybridAccount(accounts, healthTracker, tokenTracker, undefined, undefined, { pidOffsetEnabled: true }); + if (result) { + selectedIndices.add(result.index); + } } + } finally { + Object.defineProperty(process, "pid", { value: originalPid, configurable: true }); } - Object.defineProperty(process, 'pid', { value: originalPid, configurable: true }); - expect(selectedIndices.size).toBeGreaterThan(1); }); @@ -511,6 +515,40 @@ describe("selectHybridAccount", () => { ); expect(result?.index).toBe(1); }); + + it("supports named-parameter options form", () => { + const now = Date.now(); + const accounts: AccountWithMetrics[] = [ + { index: 0, isAvailable: true, lastUsed: now }, + { index: 1, isAvailable: true, lastUsed: now }, + ]; + + const baseline = selectHybridAccount(accounts, healthTracker, tokenTracker); + const named = selectHybridAccount({ + accounts, + healthTracker, + tokenTracker, + }); + + expect(named?.index).toBe(baseline?.index); + }); + + it("throws when named params accounts is not an array", () => { + expect(() => + selectHybridAccount({ + accounts: {} as unknown as AccountWithMetrics[], + healthTracker, + tokenTracker, + }), + ).toThrowError("selectHybridAccount requires accounts to be an array"); + expect(() => + selectHybridAccount({ + accounts: null as unknown as AccountWithMetrics[], + healthTracker, + tokenTracker, + }), + ).toThrowError("selectHybridAccount requires accounts to be an array"); + }); }); describe("utility functions", () => { @@ -547,25 +585,80 @@ describe("utility functions", () => { describe("exponentialBackoff", () => { it("increases delay exponentially", () => { - vi.spyOn(Math, "random").mockReturnValue(0.5); - - const delay1 = exponentialBackoff(1, 1000, 60000, 0); - const delay2 = exponentialBackoff(2, 1000, 60000, 0); - const delay3 = exponentialBackoff(3, 1000, 60000, 0); - - expect(delay2).toBe(delay1 * 2); - expect(delay3).toBe(delay1 * 4); - - vi.spyOn(Math, "random").mockRestore(); + const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.5); + try { + const delay1 = exponentialBackoff(1, 1000, 60000, 0); + const delay2 = exponentialBackoff(2, 1000, 60000, 0); + const delay3 = exponentialBackoff(3, 1000, 60000, 0); + + expect(delay2).toBe(delay1 * 2); + expect(delay3).toBe(delay1 * 4); + } finally { + randomSpy.mockRestore(); + } }); it("caps at maxMs", () => { - vi.spyOn(Math, "random").mockReturnValue(0.5); + const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.5); + try { + const result = exponentialBackoff(10, 1000, 5000, 0); + expect(result).toBe(5000); + } finally { + randomSpy.mockRestore(); + } + }); - const result = exponentialBackoff(10, 1000, 5000, 0); - expect(result).toBe(5000); + it("supports named-parameter options form", () => { + const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.5); + try { + const positional = exponentialBackoff(3, 1000, 60000, 0); + const named = exponentialBackoff({ + attempt: 3, + baseMs: 1000, + maxMs: 60000, + jitterFactor: 0, + }); + + expect(named).toBe(positional); + } finally { + randomSpy.mockRestore(); + } + }); - vi.spyOn(Math, "random").mockRestore(); + it("throws for invalid positional and named inputs before jitter is applied", () => { + const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.5); + try { + expect(() => exponentialBackoff(0, 1000, 60000, 0.1)).toThrowError( + "exponentialBackoff requires attempt to be a positive integer", + ); + expect(() => exponentialBackoff(-1, 1000, 60000, 0.1)).toThrowError( + "exponentialBackoff requires attempt to be a positive integer", + ); + expect(() => + exponentialBackoff(Number.NaN as unknown as number, 1000, 60000, 0.1), + ).toThrowError("exponentialBackoff requires attempt to be a positive integer"); + expect(() => + exponentialBackoff(Number.POSITIVE_INFINITY as unknown as number, 1000, 60000, 0.1), + ).toThrowError("exponentialBackoff requires attempt to be a positive integer"); + expect(() => + exponentialBackoff(undefined as unknown as number, 1000, 60000, 0.1), + ).toThrowError("exponentialBackoff requires attempt to be a positive integer"); + expect(() => exponentialBackoff(1, -1, 60000, 0.1)).toThrowError( + "exponentialBackoff requires baseMs to be a finite non-negative number", + ); + expect(() => exponentialBackoff(1, 1000, -1, 0.1)).toThrowError( + "exponentialBackoff requires maxMs to be a finite non-negative number", + ); + expect(() => exponentialBackoff({} as unknown as Parameters[0])).toThrowError( + "exponentialBackoff requires attempt to be a positive integer", + ); + expect(() => + exponentialBackoff({ attempt: 1, jitterFactor: 2 }), + ).toThrowError("exponentialBackoff requires jitterFactor to be between 0 and 1"); + expect(randomSpy).not.toHaveBeenCalled(); + } finally { + randomSpy.mockRestore(); + } }); }); }); diff --git a/test/server.unit.test.ts b/test/server.unit.test.ts index 41de8257..560f0a35 100644 --- a/test/server.unit.test.ts +++ b/test/server.unit.test.ts @@ -93,7 +93,7 @@ describe('OAuth Server Unit Tests', () => { expect(result.ready).toBe(false); expect(result.port).toBe(1455); expect(logError).toHaveBeenCalledWith( - expect.stringContaining('Failed to bind http://127.0.0.1:1455') + expect.stringContaining('Failed to bind http://localhost:1455') ); }); }); diff --git a/test/session-affinity.test.ts b/test/session-affinity.test.ts index 3e5b7788..a8268e20 100644 --- a/test/session-affinity.test.ts +++ b/test/session-affinity.test.ts @@ -52,4 +52,65 @@ describe("SessionAffinityStore", () => { expect(store.getPreferredAccountIndex("s2")).toBe(1); expect(store.getPreferredAccountIndex("s3")).toBe(2); }); + it("rejects invalid session keys and invalid account indices", () => { + const store = new SessionAffinityStore({ ttlMs: 10_000, maxEntries: 4 }); + store.remember(" ", 1, 1_000); + store.remember("session-x", Number.NaN, 1_000); + store.remember("session-y", -1, 1_000); + + expect(store.getPreferredAccountIndex("session-x", 2_000)).toBeNull(); + expect(store.getPreferredAccountIndex(null, 2_000)).toBeNull(); + expect(store.size()).toBe(0); + }); + + it("truncates oversized session keys and can retrieve by truncated form", () => { + const store = new SessionAffinityStore({ ttlMs: 10_000, maxEntries: 8 }); + const longKey = ` ${"x".repeat(300)} `; + const truncated = "x".repeat(256); + store.remember(longKey, 3, 1_000); + + expect(store.getPreferredAccountIndex(truncated, 2_000)).toBe(3); + }); + + it("does not evict when updating an existing key at capacity", () => { + const store = new SessionAffinityStore({ ttlMs: 60_000, maxEntries: 2 }); + store.remember("s1", 0, 1_000); + store.remember("s2", 1, 2_000); + store.remember("s2", 2, 3_000); + + expect(store.getPreferredAccountIndex("s1", 3_500)).toBe(0); + expect(store.getPreferredAccountIndex("s2", 3_500)).toBe(2); + expect(store.size()).toBe(2); + }); + + it("forgets a specific session and no-ops on blank session key", () => { + const store = new SessionAffinityStore({ ttlMs: 60_000, maxEntries: 10 }); + store.remember("s1", 0, 1_000); + store.forgetSession(" "); + store.forgetSession("s1"); + + expect(store.getPreferredAccountIndex("s1", 2_000)).toBeNull(); + expect(store.size()).toBe(0); + }); + + it("returns zero for invalid forget/reindex requests", () => { + const store = new SessionAffinityStore({ ttlMs: 60_000, maxEntries: 10 }); + store.remember("s1", 0, 1_000); + + expect(store.forgetAccount(Number.NaN)).toBe(0); + expect(store.forgetAccount(-1)).toBe(0); + expect(store.reindexAfterRemoval(Number.NaN)).toBe(0); + expect(store.reindexAfterRemoval(-1)).toBe(0); + expect(store.getPreferredAccountIndex("s1", 2_000)).toBe(0); + }); + + it("prunes expired sessions and keeps non-expired entries", () => { + const store = new SessionAffinityStore({ ttlMs: 1_000, maxEntries: 10 }); + store.remember("s1", 0, 1_000); + store.remember("s2", 1, 2_000); + + expect(store.prune(2_001)).toBe(1); + expect(store.getPreferredAccountIndex("s1", 2_001)).toBeNull(); + expect(store.getPreferredAccountIndex("s2", 2_001)).toBe(1); + }); }); diff --git a/test/settings-hub-utils.test.ts b/test/settings-hub-utils.test.ts new file mode 100644 index 00000000..24b48fbb --- /dev/null +++ b/test/settings-hub-utils.test.ts @@ -0,0 +1,277 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { DashboardDisplaySettings } from "../lib/dashboard-settings.js"; +import type { PluginConfig } from "../lib/types.js"; + +type SettingsHubTestApi = { + clampBackendNumber: (settingKey: string, value: number) => number; + formatMenuLayoutMode: (mode: "compact-details" | "expanded-rows") => string; + cloneDashboardSettings: (settings: DashboardDisplaySettings) => DashboardDisplaySettings; + withQueuedRetry: (pathKey: string, task: () => Promise) => Promise; + persistDashboardSettingsSelection: ( + selected: DashboardDisplaySettings, + keys: ReadonlyArray, + scope: string, + ) => Promise; + persistBackendConfigSelection: (selected: PluginConfig, scope: string) => Promise; +}; + +let tempRoot = ""; +const originalCodeHome = process.env.CODEX_HOME; +const originalCodeMultiAuthDir = process.env.CODEX_MULTI_AUTH_DIR; +const originalConfigPath = process.env.CODEX_MULTI_AUTH_CONFIG_PATH; + +async function loadSettingsHubTestApi(): Promise { + const module = await import("../lib/codex-manager/settings-hub.js"); + return module.__testOnly as SettingsHubTestApi; +} + +beforeEach(() => { + tempRoot = mkdtempSync(join(tmpdir(), "codex-settings-hub-test-")); + process.env.CODEX_HOME = tempRoot; + process.env.CODEX_MULTI_AUTH_DIR = tempRoot; + process.env.CODEX_MULTI_AUTH_CONFIG_PATH = join(tempRoot, "plugin-config.json"); + vi.resetModules(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + if (tempRoot.length > 0) { + rmSync(tempRoot, { recursive: true, force: true }); + } + if (originalCodeHome === undefined) { + delete process.env.CODEX_HOME; + } else { + process.env.CODEX_HOME = originalCodeHome; + } + if (originalCodeMultiAuthDir === undefined) { + delete process.env.CODEX_MULTI_AUTH_DIR; + } else { + process.env.CODEX_MULTI_AUTH_DIR = originalCodeMultiAuthDir; + } + if (originalConfigPath === undefined) { + delete process.env.CODEX_MULTI_AUTH_CONFIG_PATH; + } else { + process.env.CODEX_MULTI_AUTH_CONFIG_PATH = originalConfigPath; + } +}); + +describe("settings-hub utility coverage", () => { + it("clamps backend numeric settings by option bounds", async () => { + const api = await loadSettingsHubTestApi(); + expect(api.clampBackendNumber("fetchTimeoutMs", 250)).toBe(1_000); + expect(api.clampBackendNumber("fetchTimeoutMs", 999_999)).toBe(600_000); + expect(() => api.clampBackendNumber("unknown-setting", 5)).toThrow( + "Unknown backend numeric setting key", + ); + }); + + it("formats layout mode labels", async () => { + const api = await loadSettingsHubTestApi(); + expect(api.formatMenuLayoutMode("expanded-rows")).toBe("Expanded Rows"); + expect(api.formatMenuLayoutMode("compact-details")).toBe("Compact + Details Pane"); + }); + + it("clones dashboard settings and protects array references", async () => { + const api = await loadSettingsHubTestApi(); + const dashboard = await import("../lib/dashboard-settings.js"); + const original = await dashboard.loadDashboardDisplaySettings(); + const clone = api.cloneDashboardSettings(original); + const originalLength = original.menuStatuslineFields?.length ?? 0; + const cloneFields = clone.menuStatuslineFields ?? []; + if (!clone.menuStatuslineFields) { + clone.menuStatuslineFields = cloneFields; + } + cloneFields.push("status"); + expect(clone.menuStatuslineFields?.length).toBe(originalLength + 1); + expect(clone.menuStatuslineFields).not.toBe(original.menuStatuslineFields); + }); + + it("retries queued writes for retryable filesystem errors", async () => { + const api = await loadSettingsHubTestApi(); + let attempts = 0; + const result = await api.withQueuedRetry("settings-path", async () => { + attempts += 1; + if (attempts < 3) { + const error = new Error("busy") as NodeJS.ErrnoException; + error.code = attempts === 1 ? "EBUSY" : "EPERM"; + throw error; + } + return "ok"; + }); + expect(result).toBe("ok"); + expect(attempts).toBe(3); + }); + + it("retries queued writes for EAGAIN filesystem errors", async () => { + const api = await loadSettingsHubTestApi(); + let attempts = 0; + const result = await api.withQueuedRetry("settings-path-eagain", async () => { + attempts += 1; + if (attempts < 3) { + const error = new Error("busy") as NodeJS.ErrnoException; + error.code = "EAGAIN"; + throw error; + } + return "ok"; + }); + expect(result).toBe("ok"); + expect(attempts).toBe(3); + }); + + it.each(["ENOTEMPTY", "EACCES"] as const)( + "retries queued writes for %s filesystem errors", + async (code) => { + const api = await loadSettingsHubTestApi(); + let attempts = 0; + const result = await api.withQueuedRetry(`settings-path-${code.toLowerCase()}`, async () => { + attempts += 1; + if (attempts < 3) { + const error = new Error("busy") as NodeJS.ErrnoException; + error.code = code; + throw error; + } + return "ok"; + }); + expect(result).toBe("ok"); + expect(attempts).toBe(3); + }, + ); + + it("serializes concurrent writes for the same path key", async () => { + const api = await loadSettingsHubTestApi(); + const order: string[] = []; + let releaseFirst: (() => void) | undefined; + const firstGate = new Promise((resolve) => { + releaseFirst = resolve; + }); + + const first = api.withQueuedRetry("same-key", async () => { + order.push("first:start"); + await firstGate; + order.push("first:end"); + return "first-ok"; + }); + + const second = api.withQueuedRetry("same-key", async () => { + order.push("second:start"); + order.push("second:end"); + return "second-ok"; + }); + + await Promise.resolve(); + await Promise.resolve(); + expect(order).toEqual(["first:start"]); + + releaseFirst?.(); + + await expect(first).resolves.toBe("first-ok"); + await expect(second).resolves.toBe("second-ok"); + expect(order).toEqual(["first:start", "first:end", "second:start", "second:end"]); + }); + + it("allows concurrent writes for different path keys", async () => { + const api = await loadSettingsHubTestApi(); + const order: string[] = []; + let releaseA: (() => void) | undefined; + const gateA = new Promise((resolve) => { + releaseA = resolve; + }); + + const taskA = api.withQueuedRetry("key-a", async () => { + order.push("a:start"); + await gateA; + order.push("a:end"); + return "a"; + }); + + const taskB = api.withQueuedRetry("key-b", async () => { + order.push("b:start"); + order.push("b:end"); + return "b"; + }); + + await Promise.resolve(); + await Promise.resolve(); + expect(order).toContain("b:start"); + + releaseA?.(); + await expect(taskA).resolves.toBe("a"); + await expect(taskB).resolves.toBe("b"); + }); + + it("retries queued writes for HTTP 429 using retryAfterMs delay", async () => { + const api = await loadSettingsHubTestApi(); + vi.useFakeTimers(); + try { + let attempts = 0; + const retryAfterMs = 120; + const resultPromise = api.withQueuedRetry("settings-path-429", async () => { + attempts += 1; + if (attempts === 1) { + const error = new Error("rate limited") as Error & { + status: number; + retryAfterMs: number; + }; + error.status = 429; + error.retryAfterMs = retryAfterMs; + throw error; + } + return "ok"; + }); + + await Promise.resolve(); + await Promise.resolve(); + expect(attempts).toBe(1); + + await vi.advanceTimersByTimeAsync(retryAfterMs - 1); + expect(attempts).toBe(1); + + await vi.advanceTimersByTimeAsync(1); + const result = await resultPromise; + expect(result).toBe("ok"); + expect(attempts).toBe(2); + } finally { + vi.useRealTimers(); + } + }); + + it("persists selected dashboard keys through retry-aware save", async () => { + const api = await loadSettingsHubTestApi(); + const dashboard = await import("../lib/dashboard-settings.js"); + const base = await dashboard.loadDashboardDisplaySettings(); + const selected = api.cloneDashboardSettings(base); + selected.menuShowStatusBadge = false; + + const saved = await api.persistDashboardSettingsSelection( + selected, + ["menuShowStatusBadge"], + "account-list", + ); + expect(saved.menuShowStatusBadge).toBe(false); + + const reloaded = await dashboard.loadDashboardDisplaySettings(); + expect(reloaded.menuShowStatusBadge).toBe(false); + }); + + it("persists backend config selection", async () => { + const api = await loadSettingsHubTestApi(); + const configModule = await import("../lib/config.js"); + const selected = configModule.getDefaultPluginConfig(); + selected.fetchTimeoutMs = 12_345; + selected.streamStallTimeoutMs = 23_456; + + const saved = await api.persistBackendConfigSelection(selected, "backend"); + expect(saved.fetchTimeoutMs).toBe(12_345); + expect(saved.streamStallTimeoutMs).toBe(23_456); + + vi.resetModules(); + const freshConfigModule = await import("../lib/config.js"); + const reloaded = freshConfigModule.loadPluginConfig(); + expect(reloaded.fetchTimeoutMs).toBe(12_345); + expect(reloaded.streamStallTimeoutMs).toBe(23_456); + }); +}); diff --git a/test/storage-flagged.test.ts b/test/storage-flagged.test.ts new file mode 100644 index 00000000..7fce9e18 --- /dev/null +++ b/test/storage-flagged.test.ts @@ -0,0 +1,206 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { promises as fs, existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { tmpdir } from "node:os"; +import { + clearFlaggedAccounts, + getFlaggedAccountsPath, + getStoragePath, + loadFlaggedAccounts, + saveFlaggedAccounts, + setStoragePathDirect, +} from "../lib/storage.js"; + +const RETRYABLE_REMOVE_CODES = new Set(["EBUSY", "EPERM", "ENOTEMPTY"]); + +async function removeWithRetry( + targetPath: string, + options: { recursive?: boolean; force?: boolean }, +): Promise { + for (let attempt = 0; attempt < 6; attempt += 1) { + try { + await fs.rm(targetPath, options); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return; + } + if (!code || !RETRYABLE_REMOVE_CODES.has(code) || attempt === 5) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 25 * 2 ** attempt)); + } + } +} + +describe("flagged account storage", () => { + const testRoot = join(tmpdir(), `codex-flagged-${Math.random().toString(36).slice(2)}`); + let storagePath = ""; + + beforeEach(async () => { + await fs.mkdir(testRoot, { recursive: true }); + storagePath = join(testRoot, `accounts-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); + setStoragePathDirect(storagePath); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + setStoragePathDirect(null); + await removeWithRetry(testRoot, { recursive: true, force: true }); + }); + + it("returns an empty flagged storage object when files are absent", async () => { + const flagged = await loadFlaggedAccounts(); + expect(flagged).toEqual({ version: 1, accounts: [] }); + }); + + it("normalizes and de-duplicates flagged accounts on save/load", async () => { + await saveFlaggedAccounts({ + version: 1, + accounts: [ + { + refreshToken: " duplicate-token ", + accountId: "acct-1", + accountIdSource: "org", + accountLabel: "work", + email: "user@example.com", + enabled: true, + lastSwitchReason: "rate-limit", + rateLimitResetTimes: { codex: 12345, invalid: "skip" as never }, + coolingDownUntil: 45678, + cooldownReason: "auth-failure", + addedAt: 100, + lastUsed: 120, + flaggedAt: 150, + flaggedReason: "quota", + lastError: "429", + }, + { + refreshToken: "duplicate-token", + accountId: "acct-2", + accountIdSource: "manual", + addedAt: 200, + lastUsed: 220, + flaggedAt: 250, + }, + { + refreshToken: "", + flaggedAt: 1, + addedAt: 1, + lastUsed: 1, + } as never, + ], + }); + + const flagged = await loadFlaggedAccounts(); + + expect(flagged.accounts).toHaveLength(1); + expect(flagged.accounts[0]).toEqual( + expect.objectContaining({ + refreshToken: "duplicate-token", + accountId: "acct-2", + accountIdSource: "manual", + flaggedAt: 250, + }), + ); + }); + + it("migrates legacy blocked-account file to flagged-account storage", async () => { + const legacyPath = join(dirname(getStoragePath()), "openai-codex-blocked-accounts.json"); + await fs.mkdir(dirname(legacyPath), { recursive: true }); + await fs.writeFile( + legacyPath, + JSON.stringify( + { + version: 1, + accounts: [ + { + refreshToken: "legacy-token", + flaggedAt: 999, + addedAt: 900, + lastUsed: 950, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const flagged = await loadFlaggedAccounts(); + + expect(flagged.accounts).toHaveLength(1); + expect(flagged.accounts[0]?.refreshToken).toBe("legacy-token"); + expect(existsSync(legacyPath)).toBe(false); + expect(existsSync(getFlaggedAccountsPath())).toBe(true); + }); + + it("returns empty storage when legacy migration content is invalid", async () => { + const legacyPath = join(dirname(getStoragePath()), "openai-codex-blocked-accounts.json"); + await fs.mkdir(dirname(legacyPath), { recursive: true }); + await fs.writeFile(legacyPath, "not-json", "utf-8"); + + const flagged = await loadFlaggedAccounts(); + + expect(flagged).toEqual({ version: 1, accounts: [] }); + expect(existsSync(legacyPath)).toBe(true); + }); + + it("clears flagged storage and tolerates missing files", async () => { + await saveFlaggedAccounts({ + version: 1, + accounts: [ + { + refreshToken: "clear-me", + flaggedAt: 1, + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + expect(existsSync(getFlaggedAccountsPath())).toBe(true); + + await clearFlaggedAccounts(); + await clearFlaggedAccounts(); + + expect(existsSync(getFlaggedAccountsPath())).toBe(false); + }); + + it("cleans temporary file when flagged save fails", async () => { + const flaggedPath = getFlaggedAccountsPath(); + const originalRename = fs.rename.bind(fs); + + const renameSpy = vi.spyOn(fs, "rename").mockImplementation(async (oldPath, newPath) => { + if (newPath === flaggedPath) { + const error = new Error("forced rename failure") as NodeJS.ErrnoException; + error.code = "EACCES"; + throw error; + } + return originalRename(oldPath, newPath); + }); + + await expect( + saveFlaggedAccounts({ + version: 1, + accounts: [ + { + refreshToken: "tmp-cleanup", + flaggedAt: 1, + addedAt: 1, + lastUsed: 1, + }, + ], + }), + ).rejects.toThrow("forced rename failure"); + + const parent = dirname(flaggedPath); + const entries = existsSync(parent) ? await fs.readdir(parent) : []; + const tmpArtifacts = entries.filter((entry) => entry.includes("flagged") && entry.endsWith(".tmp")); + expect(tmpArtifacts).toHaveLength(0); + + renameSpy.mockRestore(); + }); +}); diff --git a/test/tool-utils.test.ts b/test/tool-utils.test.ts index 3b00ba0c..31988238 100644 --- a/test/tool-utils.test.ts +++ b/test/tool-utils.test.ts @@ -13,6 +13,55 @@ describe("cleanupToolDefinitions", () => { expect(cleanupToolDefinitions(tools)).toEqual(tools); }); + it("treats array parameters as non-records and leaves tool unchanged", () => { + const tools = [{ + type: "function", + function: { + name: "array-params", + parameters: [] as unknown, + }, + }]; + + const result = cleanupToolDefinitions(tools as never) as typeof tools; + expect(result[0]).toBe(tools[0]); + }); + + it("returns tool unchanged when parameters contain circular references", () => { + const circular: Record = { + type: "object", + properties: { a: { type: "string" } }, + }; + circular.self = circular; + const tools = [{ + type: "function", + function: { + name: "circular-params", + parameters: circular, + }, + }]; + + const result = cleanupToolDefinitions(tools as never) as typeof tools; + expect(result[0]).toBe(tools[0]); + }); + + it("returns tool unchanged when parameters contain bigint values", () => { + const tools = [{ + type: "function", + function: { + name: "bigint-params", + parameters: { + type: "object", + properties: { + size: 1n, + }, + }, + }, + }]; + + const result = cleanupToolDefinitions(tools as never) as typeof tools; + expect(result[0]).toBe(tools[0]); + }); + it("filters required array to only existing properties", () => { const tools = [{ type: "function", diff --git a/test/utils.test.ts b/test/utils.test.ts index 6c139040..e02ac5b9 100644 --- a/test/utils.test.ts +++ b/test/utils.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { isRecord, nowMs, toStringValue, sleep } from '../lib/utils.js'; +import { isRecord, isAbortError, nowMs, toStringValue, sleep } from '../lib/utils.js'; describe('Utils Module', () => { describe('isRecord', () => { @@ -44,6 +44,24 @@ describe('Utils Module', () => { }); }); + describe('isAbortError', () => { + it('returns true when error name is AbortError', () => { + const abortError = Object.assign(new Error('aborted'), { name: 'AbortError' }); + expect(isAbortError(abortError)).toBe(true); + }); + + it('returns true when error code is ABORT_ERR', () => { + const abortError = Object.assign(new Error('aborted'), { code: 'ABORT_ERR' }); + expect(isAbortError(abortError)).toBe(true); + }); + + it('returns false for non-abort values', () => { + expect(isAbortError(new Error('network error'))).toBe(false); + expect(isAbortError({ name: 'AbortError' })).toBe(false); + expect(isAbortError(null)).toBe(false); + }); + }); + describe('nowMs', () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/vitest.config.ts b/vitest.config.ts index 92b730da..929cd21d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,5 +1,17 @@ import { defineConfig } from 'vitest/config'; +const forcePlainTestOutput = + process.env.CODEX_PLAIN_LOGS === '1' || + process.env.NO_COLOR === '1' || + process.env.CI === 'true' || + process.env.GITHUB_ACTIONS === 'true' || + !process.stdout.isTTY; + +if (forcePlainTestOutput) { + process.env.NO_COLOR = '1'; + process.env.FORCE_COLOR = '0'; +} + export default defineConfig({ test: { globals: true,