diff --git a/.github/AGENTS.md b/.github/AGENTS.md new file mode 100644 index 000000000..fc363e66f --- /dev/null +++ b/.github/AGENTS.md @@ -0,0 +1,26 @@ +# GitHub automation instructions + +This file applies to `.github/` and inherits the repository-wide rules in `/AGENTS.md`. + +## Security boundary + +Every workflow, ownership, branch-enforcement, release, or repository-automation +change requires explicit security review under `MAINTAINERS.md`. + +## Workflow rules + +- Grant the minimum required `permissions`. +- Pin third-party actions to immutable full commit SHAs. +- Preserve the human-readable version comment beside each pinned action. +- Do not run untrusted pull-request code with secrets or write permissions. +- Treat `pull_request_target`, workflow dispatch, reusable workflows, artifacts, caches, and generated command input as trust boundaries. +- Do not broaden triggers, write permissions, token exposure, release eligibility, or publish capability without an explicit task requirement. +- Preserve cross-platform coverage where the workflow currently promises Linux, macOS, and Windows behavior. +- Keep branch-enforcement text synchronized with `AGENTS.md`, `MAINTAINERS.md`, and the public contributing guide. + +## Validation + +- Inspect the complete workflow diff, including event triggers, permissions, conditions, interpolation, and shell behavior. +- Run the local commands represented by changed workflow steps where possible. +- Run `bun run prepush` for CI, release, dependency, packaging, or cross-platform workflow changes. +- Do not claim the workflow itself passed until GitHub Actions reports success for the exact commit. diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 556398275..297343edf 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -14,5 +14,7 @@ /src/server/management-api.ts @lidge-jun @Ingwannu # Governance and security policy +/AGENTS.md @lidge-jun @Ingwannu +**/AGENTS.md @lidge-jun @Ingwannu /MAINTAINERS.md @lidge-jun @Ingwannu /SECURITY.md @lidge-jun @Ingwannu diff --git a/.github/scripts/enforce-pr-target.test.cjs b/.github/scripts/enforce-pr-target.test.cjs new file mode 100644 index 000000000..018f8b3c3 --- /dev/null +++ b/.github/scripts/enforce-pr-target.test.cjs @@ -0,0 +1,46 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { describe, it } = require("node:test"); +const assert = require("node:assert/strict"); + +describe("enforce-pr-target workflow", () => { + const workflowPath = path.join(__dirname, "../workflows/enforce-pr-target.yml"); + const workflow = fs.readFileSync(workflowPath, "utf8"); + + it("uses pull_request_target without checking out PR head code", () => { + assert.match(workflow, /pull_request_target:/); + assert.doesNotMatch( + workflow, + /actions\/checkout@/, + "wrong-branch enforcer must not check out untrusted PR code", + ); + }); + + it("grants contents:write so draft GraphQL mutations work with GITHUB_TOKEN", () => { + // convertPullRequestToDraft / markPullRequestReadyForReview fail with + // "Resource not accessible by integration" when contents stays unset/read + // (seen on #626). Assert the real permissions block, not comment text + // that also mentions these scopes. + const permissionsBlock = workflow.match(/^permissions:\n((?:[ \t]+.+\n)+)/m); + assert.ok(permissionsBlock, "workflow must declare a top-level permissions block"); + const lines = permissionsBlock[1] + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .sort(); + assert.deepEqual(lines, ["contents: write", "pull-requests: write"]); + }); + + it("fails the required check on a wrong base even if draft conversion fails", () => { + assert.match(workflow, /core\.setFailed\(/); + assert.match(workflow, /draftConversionFailed/); + assert.match(workflow, /Could not convert pull request to draft/); + }); + + it("soft-fails ready-for-review restoration the same way", () => { + assert.match(workflow, /readyConversionFailed/); + assert.match(workflow, /Could not mark pull request ready for review/); + }); +}); diff --git a/.github/scripts/issue-quality.cjs b/.github/scripts/issue-quality.cjs index b973b3a92..681cdc55b 100644 --- a/.github/scripts/issue-quality.cjs +++ b/.github/scripts/issue-quality.cjs @@ -25,19 +25,29 @@ function unwrapSingleEnclosingFence(text) { return match[2]; } -function isPlaceholderOnlyValue(raw) { - if (typeof raw !== "string") return false; +/** + * Shared strip/trim/unwrap used by placeholder and unusable-stand-in matchers. + * Returns null when the value is absent after normalisation. + */ +function normalizeRawSectionValue(raw) { + if (typeof raw !== "string") return null; let value = raw.replace(//g, "").trim(); - if (!value) return false; + if (!value) return null; - // A lone fenced block whose entire body is a placeholder is still placeholder - // text (e.g. ```text\nN/A\n```), not a real example. + // A lone fenced block whose entire body is a stand-in is still a stand-in + // (e.g. ```text\nN/A\n```), not a real example. const unwrapped = unwrapSingleEnclosingFence(value); if (unwrapped !== null) { value = unwrapped.trim(); - if (!value) return false; + if (!value) return null; } + return value; +} + +function isPlaceholderOnlyValue(raw) { + const value = normalizeRawSectionValue(raw); + if (value === null) return false; return PLACEHOLDER_ONLY_RE.test(value); } @@ -398,6 +408,20 @@ function isPlaceholder(text) { return isPlaceholderOnlyValue(text); } +/** + * True when Version is an "I don't know" stand-in rather than an install id. + * Kept separate from PLACEHOLDER_ONLY_RE so legacy N/A / No response soft-pass + * behaviour is unchanged. + */ +const UNUSABLE_VERSION_RE = + /^[\s_*~`]*(?:unknown|unkown|uknown|don'?t\s+know|do\s+not\s+know|idk|dunno|not\s+sure|unsure|\?+|모름|잘\s*모름|모르겠(?:습니다|음)?|不明|わからない|分からない|不知道|不清楚|keine\s+ahnung|wei[sß]{1,2}\s+nicht)[\s_*~`]*[.!?]*$/i; + +function isUnusableVersion(raw) { + const value = normalizeRawSectionValue(raw); + if (value === null) return false; + return UNUSABLE_VERSION_RE.test(value); +} + const CJK_RE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/gu; @@ -433,6 +457,16 @@ function isTooTerseFeatureSection(text) { return true; } +/** + * Bug Reproduction needs steps or concrete signals. A title-like phrase with + * no commands, paths, digits, or product keywords is not actionable. + */ +function isTooTerseBugReproduction(text) { + if (isEmpty(text) || isPlaceholder(text)) return false; + if (hasConcreteDetail(text)) return false; + return countWords(text) < 12; +} + /** * Check if raw section text is a placeholder-only variant without relying on * clean() first. Used to distinguish intentionally blank optional fields @@ -628,6 +662,8 @@ function validateIssue(issue) { const repro = extractSection(body, "Reproduction"); const version = extractSection(body, "Version"); const os = extractSection(body, "Operating system") ?? extractSection(body, "OS"); + // New Bug report template always includes Client or integration. + const isNewBugForm = extractSection(body, "Client or integration") !== null; if (isEmpty(summary) && isEmpty(repro)) { // Soft-pass substantial non-English / freeform structured reports once @@ -655,17 +691,56 @@ function validateIssue(issue) { if (isEmpty(repro)) { reasons.push("Reproduction is empty."); guidance.push("List the exact steps to reproduce the problem."); + } else if (!softPass && isTooTerseBugReproduction(repro)) { + reasons.push("Reproduction is too vague to act on."); + guidance.push("List exact steps, commands, and the observed failure — not only a short phrase."); } } - // Required environment fields removed after submission. - // Only fire when the headings exist in the body (new form). Legacy bug - // reports never had Version or OS fields, so null means absent, not removed. - // Skip when the raw value is a "No response" placeholder -- the old form had - // both fields as optional, so legacy issues legitimately contain those headings - // with the GitHub placeholder. Only close when the field was actively cleared. - if (!softPass && version !== null && os !== null && isEmpty(version) && isEmpty(os) && - !isRawPlaceholder(version) && !isRawPlaceholder(os)) { + // Version "Unknown" / "모름" / "idk" is never actionable, on any form. + if (!softPass && version !== null && isUnusableVersion(version)) { + reasons.push("Version is missing or unknown."); + guidance.push("Report the installed `@bitkyc08/opencodex` version (for example `2.7.42`) or a commit SHA from `ocx --version`."); + } else if ( + !softPass && + isNewBugForm && + version !== null && + (isEmpty(version) || isRawPlaceholder(version)) + ) { + // New form requires Version. Legacy N/A / No response soft-pass stays + // only for bodies without Client or integration. + reasons.push("Version is missing."); + guidance.push("Add your OpenCodex version so we can reproduce the environment."); + } + + if (!softPass && isNewBugForm && os !== null && isUnusableVersion(os)) { + reasons.push("Operating system is missing or unknown."); + guidance.push("Add your OS name and version (for example Windows 11 24H2)."); + } else if ( + !softPass && + isNewBugForm && + os !== null && + (isEmpty(os) || isRawPlaceholder(os)) + ) { + reasons.push("Operating system is missing."); + guidance.push("Add your OS name and version (for example Windows 11 24H2)."); + } + + // Required environment fields removed after submission on bodies that are + // not the new form (no Client or integration). Legacy reports never had + // Version or OS fields, so null means absent, not removed. Skip when the + // raw value is a "No response" placeholder — the old form had both fields + // as optional. Only close when the field was actively cleared. + if ( + !softPass && + !isNewBugForm && + version !== null && + os !== null && + isEmpty(version) && + isEmpty(os) && + !isRawPlaceholder(version) && + !isRawPlaceholder(os) + ) { reasons.push("Version and Operating system are both missing."); guidance.push("Add your OpenCodex version and OS so we can reproduce the environment."); } @@ -873,6 +948,7 @@ module.exports = { isPlaceholderOnlyValue, isPlaceholder, isRawPlaceholder, + isUnusableVersion, countWords, hasConcreteDetail, labelForKind, diff --git a/.github/scripts/issue-quality.test.cjs b/.github/scripts/issue-quality.test.cjs index 6dbe61e3c..9c8f9bb86 100644 --- a/.github/scripts/issue-quality.test.cjs +++ b/.github/scripts/issue-quality.test.cjs @@ -16,6 +16,7 @@ const { isPlaceholderOnlyValue, isPlaceholder, isRawPlaceholder, + isUnusableVersion, countWords, hasConcreteDetail, rejectsWorkflowDispatchPullRequest, @@ -693,6 +694,162 @@ describe("validateIssue - bug", () => { assert.equal(result.valid, false); assert.ok(result.reasons.some((r) => r.includes("Version"))); }); + + it("rejects unknown / don't-know Version values (#624)", () => { + const versions = [ + "Unknown", + "Uknown", + "unkown", + "Don't know", + "dont know", + "idk", + "모름", + "잘 모름", + "?", + "???", + ]; + for (const version of versions) { + const body = [ + "### Client or integration", + "Codex CLI", + "### Area", + "CLI", + "### Summary", + "The OpenCodex proxy keeps dropping the Codex CLI connection mid-request.", + "### Reproduction", + "1. ocx start --port 10100", + "2. Send any Codex CLI request through the proxy", + "3. Observe the connection drop", + "### Version", + version, + "### Operating system", + "Windows 11", + ].join("\n"); + const result = validateIssue({ + title: "Unexpected interruption continues to occur", + body, + labels: ["bug"], + }); + assert.equal(result.kind, "bug"); + assert.equal( + result.valid, + false, + `Expected unusable Version "${version}" to be invalid, got: ${result.reasons.join("; ")}`, + ); + assert.ok( + result.reasons.some((r) => /Version/i.test(r) && /unknown|missing/i.test(r)), + `Expected Version unknown/missing reason for "${version}", got: ${result.reasons.join("; ")}`, + ); + } + }); + + it("rejects issue #624-style low-effort new-form bug", () => { + const body = [ + "### Client or integration", + "Codex CLI", + "### Area", + "CLI", + "### Summary", + "CLI로 확인해봤는데 오픈코덱스 프록시가 중간에 자꾸 연결이 끊어져서 그런거라고 합니다.", + "", + "수정 바랍니다.", + "### Reproduction", + "예기치않게중단됨", + "### Version", + "모름", + "### Operating system", + "윈11", + "### Provider and model", + "_No response_", + "### Logs or error output", + "```shell", + "", + "```", + ].join("\n"); + const result = validateIssue({ + title: "Unexpected interruption continues to occur", + body, + labels: ["bug"], + }); + assert.equal(result.kind, "bug"); + assert.equal(result.valid, false); + assert.ok(result.reasons.some((r) => /Version/i.test(r))); + assert.ok(result.reasons.some((r) => /Reproduction/i.test(r) && /vague|empty/i.test(r))); + }); + + it("rejects a new-form bug with a usable Version but placeholder OS", () => { + const body = [ + "### Client or integration", + "Codex CLI", + "### Area", + "CLI", + "### Summary", + "Proxy returns 502 when streaming is enabled on Windows.", + "### Reproduction", + "1. ocx start", + "2. Send a streaming /v1/responses request", + "### Version", + "2.7.42", + "### Operating system", + "No response", + ].join("\n"); + const result = validateIssue({ title: "Streaming 502", body, labels: ["bug"] }); + assert.equal(result.kind, "bug"); + assert.equal(result.valid, false); + assert.ok(result.reasons.some((r) => /Operating system/i.test(r))); + }); + + it("rejects a new-form bug whose Reproduction is only a vague phrase", () => { + const body = [ + "### Client or integration", + "Codex CLI", + "### Area", + "CLI", + "### Summary", + "The OpenCodex proxy keeps dropping the Codex CLI connection mid-request.", + "### Reproduction", + "Unexpected interruption", + "### Version", + "2.7.42", + "### Operating system", + "Windows 11", + ].join("\n"); + const result = validateIssue({ + title: "Unexpected interruption continues to occur", + body, + labels: ["bug"], + }); + assert.equal(result.kind, "bug"); + assert.equal(result.valid, false); + assert.ok(result.reasons.some((r) => /Reproduction/i.test(r) && /vague/i.test(r))); + }); + + it("rejects unknown Operating system stand-ins on the new bug form", () => { + const body = [ + "### Client or integration", + "Codex CLI", + "### Area", + "CLI", + "### Summary", + "The OpenCodex proxy keeps dropping the Codex CLI connection mid-request.", + "### Reproduction", + "1. ocx start --port 10100", + "2. Send any Codex CLI request through the proxy", + "3. Observe the connection drop", + "### Version", + "2.7.42", + "### Operating system", + "Unknown", + ].join("\n"); + const result = validateIssue({ + title: "Unexpected interruption continues to occur", + body, + labels: ["bug"], + }); + assert.equal(result.kind, "bug"); + assert.equal(result.valid, false); + assert.ok(result.reasons.some((r) => /Operating system/i.test(r))); + }); }); // --------------------------------------------------------------------------- @@ -839,6 +996,16 @@ describe("normalisation", () => { assert.equal(clean("Not available!"), ""); }); + it("detects unusable Version stand-ins without treating them as generic placeholders", () => { + for (const value of ["Unknown", "Uknown", "모름", "idk", "don't know"]) { + assert.equal(isUnusableVersion(value), true, value); + assert.equal(isPlaceholderOnlyValue(value), false, value); + } + for (const value of ["2.7.42", "N/A", "No response", "main@abc1234"]) { + assert.equal(isUnusableVersion(value), false, value); + } + }); + it("does not treat sentences containing placeholder phrases as empty", () => { assert.equal(clean("This is N/A for voice mode today."), "This is N/A for voice mode today."); assert.equal(clean("Not applicable to Claude Code."), "Not applicable to Claude Code."); diff --git a/.github/workflows/enforce-pr-target.yml b/.github/workflows/enforce-pr-target.yml index da76509d9..6e8a52af6 100644 --- a/.github/workflows/enforce-pr-target.yml +++ b/.github/workflows/enforce-pr-target.yml @@ -8,7 +8,13 @@ on: - edited - ready_for_review +# pull-requests:write covers title/comment updates. +# contents:write is required for convertPullRequestToDraft / +# markPullRequestReadyForReview GraphQL mutations with GITHUB_TOKEN +# (otherwise: "Resource not accessible by integration"). This workflow +# never checks out PR head code. permissions: + contents: write pull-requests: write concurrency: @@ -58,6 +64,7 @@ jobs: comment.user?.login === "github-actions[bot]" && comment.body?.includes(COMMENT_MARKER) ); + let botCommentId = botComment?.id ?? null; function parseState(body) { const match = body?.match(STATE_PATTERN); @@ -90,23 +97,24 @@ jobs: } async function upsertComment(body) { - if (botComment) { + if (botCommentId) { await github.rest.issues.updateComment({ owner, repo, - comment_id: botComment.id, + comment_id: botCommentId, body }); return; } - await github.rest.issues.createComment({ + const created = await github.rest.issues.createComment({ owner, repo, issue_number: pull_number, body }); + botCommentId = created.data.id; } async function convertToDraft() { @@ -158,10 +166,12 @@ jobs: // Wrong branch: // - add the title prefix - // - convert ready PRs to draft + // - convert ready PRs to draft (best-effort) // - post or update the explanation + // - fail the required check so merge stays blocked even if draft fails // - remember which changes were made by this workflow if (wrongBase) { + const willPrefixTitle = !pr.title.startsWith(TITLE_PREFIX); const state = storedState?.active ? { ...storedState } : { @@ -171,20 +181,15 @@ jobs: titlePrefixedByBot: false }; - if (!pr.draft) { - state.autoDraftedByBot = true; - } - - if (!pr.title.startsWith(TITLE_PREFIX)) { + // Claim title ownership before mutating so a crash after the + // title write still records what the bot owns. Do not claim a + // new draft here — only after convertToDraft succeeds. + if (willPrefixTitle) { state.titlePrefixedByBot = true; } - const draftExplanation = state.autoDraftedByBot - ? "This pull request is being kept as a draft automatically. Once the target branch is corrected, it will be marked ready for review again." - : "This pull request was already a draft. Its draft status will be preserved after the target branch is corrected."; + let draftConversionFailed = false; - // Store the restoration state before changing the PR, allowing - // a rerun to recover if an API request fails partway through. await upsertComment( [ COMMENT_MARKER, @@ -194,15 +199,11 @@ jobs: "", `This pull request currently targets ${inlineCode(pr.base.ref)}, but pull requests must target one of ${ALLOWED_BASES.map(inlineCode).join(" or ")}.`, "", - `Its title has been prefixed with ${inlineCode(TITLE_PREFIX.trim())}.`, - "", - `@${pr.user.login} Please retarget this PR to ${inlineCode(DEFAULT_BASE)}. Most contributions go to ${inlineCode(DEFAULT_BASE)} first; use ${inlineCode("dev2-go")} only for scoped Go native-port work. \`main\` receives only release promotions. See our [Contributing guide](https://lidge-jun.github.io/opencodex/contributing/) for details. Thanks! 🙏`, - "", - draftExplanation + "Recording ownership state before applying title/draft changes…" ].join("\n") ); - if (!pr.title.startsWith(TITLE_PREFIX)) { + if (willPrefixTitle) { await github.rest.pulls.update({ owner, repo, @@ -212,9 +213,58 @@ jobs: } if (!pr.draft) { - await convertToDraft(); + try { + await convertToDraft(); + state.autoDraftedByBot = true; + // Checkpoint after a successful draft so ownership survives + // if the final explanation write fails. + await upsertComment( + [ + COMMENT_MARKER, + stateMarker(state), + "", + "⚠️ **Wrong target branch**", + "", + `This pull request currently targets ${inlineCode(pr.base.ref)}, but pull requests must target one of ${ALLOWED_BASES.map(inlineCode).join(" or ")}.`, + "", + "Draft conversion succeeded; finalising explanation…" + ].join("\n") + ); + } catch (error) { + draftConversionFailed = true; + state.autoDraftedByBot = false; + core.warning( + `Could not convert pull request to draft: ${error.message}` + ); + } } + const draftExplanation = draftConversionFailed + ? "Automatic draft conversion failed (token cannot change draft status). Please convert this pull request to a draft manually, or retarget it to the correct base. The required `enforce-target` check will keep failing until the base is corrected." + : state.autoDraftedByBot + ? "This pull request is being kept as a draft automatically. Once the target branch is corrected, it will be marked ready for review again." + : "This pull request was already a draft. Its draft status will be preserved after the target branch is corrected."; + + await upsertComment( + [ + COMMENT_MARKER, + stateMarker(state), + "", + "⚠️ **Wrong target branch**", + "", + `This pull request currently targets ${inlineCode(pr.base.ref)}, but pull requests must target one of ${ALLOWED_BASES.map(inlineCode).join(" or ")}.`, + "", + `Its title has been prefixed with ${inlineCode(TITLE_PREFIX.trim())}.`, + "", + `@${pr.user.login} Please retarget this PR to ${inlineCode(DEFAULT_BASE)}. Most contributions go to ${inlineCode(DEFAULT_BASE)} first; use ${inlineCode("dev2-go")} only for scoped Go native-port work. \`main\` receives only release promotions. See our [Contributing guide](https://lidge-jun.github.io/opencodex/contributing/) for details. Thanks! 🙏`, + "", + draftExplanation + ].join("\n") + ); + + core.setFailed( + `Pull request targets ${pr.base.ref}; allowed bases are ${ALLOWED_BASES.join(", ")}.` + ); return; } @@ -243,27 +293,46 @@ jobs: // Mark the PR ready only if this workflow originally converted it // to draft. Intentionally drafted PRs remain drafts. + let readyConversionFailed = false; if ( storedState.autoDraftedByBot && pr.draft ) { - await markReadyForReview(); + try { + await markReadyForReview(); + } catch (error) { + readyConversionFailed = true; + core.warning( + `Could not mark pull request ready for review: ${error.message}` + ); + } } - const completedState = { - version: 1, - active: false, - autoDraftedByBot: false, - titlePrefixedByBot: false - }; + const completedState = readyConversionFailed + ? { + // Keep ownership active so a later edited/reopened/ + // ready_for_review event retries markReadyForReview. + version: 1, + active: true, + autoDraftedByBot: true, + titlePrefixedByBot: false + } + : { + version: 1, + active: false, + autoDraftedByBot: false, + titlePrefixedByBot: false + }; const titleResult = storedState.titlePrefixedByBot ? `The ${inlineCode(TITLE_PREFIX.trim())} title prefix has been removed.` : "The title was left unchanged."; - const draftResult = storedState.autoDraftedByBot - ? "The pull request has been marked ready for review again." - : "Its existing draft status has been preserved."; + const draftResult = readyConversionFailed + ? "Automatic ready-for-review conversion failed; please mark the pull request ready manually if it is still a draft." + : storedState.autoDraftedByBot + ? "The pull request has been marked ready for review again." + : "Its existing draft status has been preserved."; await upsertComment( [ diff --git a/.github/workflows/issue-quality-tests.yml b/.github/workflows/issue-quality-tests.yml index 76d7e97ad..c6c72b66a 100644 --- a/.github/workflows/issue-quality-tests.yml +++ b/.github/workflows/issue-quality-tests.yml @@ -8,6 +8,7 @@ on: - ".github/scripts/issue-quality.test.cjs" - ".github/scripts/pr-labeler.cjs" - ".github/scripts/pr-labeler.test.cjs" + - ".github/scripts/enforce-pr-target.test.cjs" - ".github/scripts/issue-translation.cjs" - ".github/scripts/issue-translation.test.cjs" - ".github/scripts/issue-triage.cjs" @@ -15,6 +16,7 @@ on: - ".github/scripts/parse-issue-translation-response.cjs" - ".github/scripts/parse-issue-translation-response.test.cjs" - ".github/workflows/enforce-issue-quality.yml" + - ".github/workflows/enforce-pr-target.yml" - ".github/workflows/pr-labeler.yml" - ".github/workflows/issue-triage.yml" - ".github/workflows/issue-quality-tests.yml" @@ -25,6 +27,7 @@ on: - ".github/scripts/issue-quality.test.cjs" - ".github/scripts/pr-labeler.cjs" - ".github/scripts/pr-labeler.test.cjs" + - ".github/scripts/enforce-pr-target.test.cjs" - ".github/scripts/issue-translation.cjs" - ".github/scripts/issue-translation.test.cjs" - ".github/scripts/issue-triage.cjs" @@ -32,6 +35,7 @@ on: - ".github/scripts/parse-issue-translation-response.cjs" - ".github/scripts/parse-issue-translation-response.test.cjs" - ".github/workflows/enforce-issue-quality.yml" + - ".github/workflows/enforce-pr-target.yml" - ".github/workflows/pr-labeler.yml" - ".github/workflows/issue-triage.yml" - ".github/workflows/issue-quality-tests.yml" @@ -53,6 +57,7 @@ jobs: run: | node --test .github/scripts/issue-quality.test.cjs node --test .github/scripts/pr-labeler.test.cjs + node --test .github/scripts/enforce-pr-target.test.cjs node --test .github/scripts/issue-translation.test.cjs node --test .github/scripts/issue-triage.test.cjs node --test .github/scripts/parse-issue-translation-response.test.cjs diff --git a/AGENTS.md b/AGENTS.md index b3e9989d2..2502e1711 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,12 +16,16 @@ Bun-native TypeScript with no separate server compile step. `tests/helpers/`, broader scenarios in `tests/e2e-style/`. - `gui/` — React + Vite dashboard; packaged output is served from `gui/dist`. - `docs-site/` — public docs (Astro + Starlight), deployed to GitHub Pages. +- `go/` — Go native runtime (primary on the `dev2-go` line during transition). - `structure/` — maintainer invariants and architecture notes; read before changing shared subsystems. - `scripts/` — release and maintenance tooling; `scripts/release.ts` is the release authority. - `devlog/` — planning and investigation artifacts (mostly gitignored). +Read the nearest nested `AGENTS.md` before changing files in a scoped +directory (`src/`, `gui/`, `docs-site/`, `scripts/`, `.github/`). + ## Commands ```bash @@ -52,6 +56,21 @@ non-trivial change. CI runs these on Linux, Windows, and macOS. from `dev` (releases, docs deploys). Do not open feature PRs against `main`. - `preview` — prerelease train (`x.y.z-preview.*` versions). +### Transition to `dev2-go` + +The project is moving its primary runtime to the Go native port, so `dev2-go` +has to keep receiving everything that lands on `dev`. Pull requests against +`dev` stay welcome and unchanged — the extra work belongs to the maintainer who +merges them. + +A merge into `dev` does not finish the task. The merging maintainer also +rebases that work onto `dev2-go`, ports whatever needs a Go counterpart under +`go/`, and merges the port. The item is done only when both lines carry the +change. If a change has no Go counterpart, say so in the merge or tracking +issue; if the port has to wait, open a `needs-go-port` tracking issue against +`dev2-go` naming the source commits before closing out the `dev` merge. +[`MAINTAINERS.md`](./MAINTAINERS.md) holds the authoritative wording. + The Claude Desktop integration formerly carried on the `claudedesktop` branch is now fully merged into `dev`, and that branch has been retired. Desktop work continues as normal pull requests against `dev`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cbf673adf..4a99de54f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,6 +19,13 @@ Thanks for helping with opencodex. - `main` — releases only; moves by maintainer-controlled promotion from `dev`. - `preview` — prerelease train. +While the project moves its primary runtime to the Go native port, `dev2-go` +has to keep receiving everything that lands on `dev`. Nothing changes for +contributors: keep opening pull requests against `dev`. After merging, a +maintainer rebases the work onto `dev2-go` and ports whatever needs a Go +counterpart, and the item is finished only once both lines carry it. See +[`MAINTAINERS.md`](./MAINTAINERS.md) for the full rule. + Porting and rebase pull requests are welcome: carrying a fix across integration lines, or rebasing a stale branch onto the current head, is normal contribution. Note the source commits in the description. diff --git a/MAINTAINERS.md b/MAINTAINERS.md index bd5f3a487..8f27d3c97 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -8,12 +8,16 @@ review and merge policy. | GitHub account | Project role | Responsibilities | | --- | --- | --- | | [@lidge-jun](https://github.com/lidge-jun) | Project owner | Project direction, releases, repository administration, and final governance decisions | -| [@Ingwannu](https://github.com/Ingwannu) | Maintainer | Issue and pull-request triage, `dev` integration, security review, and repository maintenance | -| [@Wibias](https://github.com/Wibias) | Maintainer | Issue and pull-request triage, `dev` integration, and provider/CI maintenance | +| [@Ingwannu](https://github.com/Ingwannu) | Maintainer | Issue and pull-request triage, `dev` and `dev2-go` integration, security review, and repository maintenance | +| [@Wibias](https://github.com/Wibias) | Maintainer | Issue and pull-request triage, `dev` and `dev2-go` integration, and provider/CI maintenance | The table describes project responsibilities. Actual repository permissions remain controlled through GitHub repository settings. +Integration duty spans both lines while the Go native port is in transition: a +maintainer who merges into `dev` also carries that work onto `dev2-go`. The +rule is written out under [Review and merge policy](#review-and-merge-policy). + ## Review and merge policy - Pull requests target `dev` by default. `dev2-go` is a parallel integration @@ -23,6 +27,27 @@ through GitHub repository settings. distinguish scoped Go port work from anything else aimed at `dev2-go`, so that boundary is enforced in review: redirect an out-of-scope pull request to `dev` rather than treating the automation's silence as approval. +- **Transition to `dev2-go` (current phase).** The project is moving its + primary runtime to the Go native port, so `dev2-go` has to keep receiving + everything that lands on `dev`. Contributors still open pull requests against + `dev`, and that stays allowed — the extra work is the maintainer's, not + theirs. A merge into `dev` is therefore not the end of the task. The + maintainer who merges it also rebases that work onto `dev2-go`, ports + whatever needs a Go counterpart under `go/`, and merges the port. The item is + finished only when both lines carry the change. + - Do the rebase and the port in the same session as the merge. A `dev` merge + left unported is the failure mode this rule exists to prevent: `dev2-go` + silently falls behind, and the divergence surfaces later as a conflict + nobody has context for. + - When a change genuinely has no Go counterpart — docs, TypeScript-only + paths the port does not cover yet, GUI-only work — record that decision in + the merge or the tracking issue. An unexplained missing port is + indistinguishable from a forgotten one. + - If the port cannot be completed immediately (a blocking dependency, a + subsystem the port has not reached), open a tracking issue against + `dev2-go` before closing out the `dev` merge, label it `needs-go-port`, + and name the source commits. That label is the durable signal that a + deferred port is intentional, not forgotten. - A pull request requires approval from at least one maintainer and successful required CI checks before merge. - Authors do not approve their own pull requests. @@ -46,16 +71,24 @@ Adding or removing a maintainer requires: - 2026-07-27 — [@Wibias](https://github.com/Wibias) added as a maintainer. Requirement 1 (agreement from the project owner) is met: the owner requested - the addition. **Requirement 2 (review by another current maintainer) is still - open** and is satisfied when this change is reviewed and merged; until then - this entry records an in-progress change, not a completed procedure. - Requirement 3 is met by this file and `.github/CODEOWNERS`. - - Scope covers issue and pull-request triage, `dev` integration, and - provider/CI maintenance. Security-boundary ownership in `.github/CODEOWNERS` - is deliberately unchanged: authentication, credential handling, GitHub - Actions, and release automation keep the two owners already listed for those - paths, so this addition does not widen the review surface for them. + the addition. **Requirement 2 (review by another current maintainer) was + never satisfied in the form this document describes.** The three commits that + carried the addition (`a2693c02`, `dc3a4ade`, `02bbd47a`) landed on `dev` as + direct owner pushes with no associated pull request, so no second maintainer + reviewed them. Requirement 3 is met by this file and `.github/CODEOWNERS`. + The addition is in effect regardless: @Wibias holds write access on the + repository and has been merging pull requests since 2026-07-26. This entry + records the gap rather than papering over it — a later maintainer change + should go through a reviewed pull request. + + Scope covers issue and pull-request triage, `dev` and `dev2-go` integration, + and provider/CI maintenance. While the Go native port is in transition, + integration duty includes carrying merged `dev` work onto `dev2-go` as + described in the review and merge policy above. Security-boundary ownership + in `.github/CODEOWNERS` is deliberately unchanged: authentication, credential + handling, GitHub Actions, and release automation keep the two owners already + listed for those paths, so this addition does not widen the review surface + for them. CODEOWNERS requests reviews rather than enforcing them — no branch protection rule is configured on this repository, so code-owner approval is a convention diff --git a/README.md b/README.md index 4cbd50f62..cd8d98074 100644 --- a/README.md +++ b/README.md @@ -240,11 +240,15 @@ next Codex session. opencodex keeps these behaviors: - **Existing sessions keep affinity.** A thread id is bound to the selected account and reused on later turns, so a long request or a mobile/SSH-attached session keeps using the same account. + Pausing an account clears its affinity map: in-flight requests keep captured credentials, but + subsequent turns are re-routed and cannot reuse the paused account. - **New sessions can auto-route.** When auto-switch is enabled, opencodex compares the hottest known quota window across 5h, weekly, and 30d usage, then picks a lower-usage eligible account for new sessions once the active account crosses the threshold. - **Quota lookup is built in.** The dashboard can refresh all account quotas in one click, and the - request log labels pool traffic with non-PII account ordinals. + request log labels pool traffic with non-PII account ordinals. **Pause exhausted** refreshes + eligible accounts that have credentials and pauses only those whose relevant quota window is + freshly confirmed at 100%; accounts without credentials and unknown or failed refreshes stay unchanged. - **Failures fail closed.** Token failures mark reauthentication instead of falling back to another credential silently; 429 quota responses put the account in cooldown and can fail over future work to another eligible pool account. diff --git a/docs-site/AGENTS.md b/docs-site/AGENTS.md new file mode 100644 index 000000000..eb3fa13fa --- /dev/null +++ b/docs-site/AGENTS.md @@ -0,0 +1,30 @@ +# Documentation-site instructions + +This file applies to `docs-site/` and inherits the repository-wide rules in `/AGENTS.md`. + +## Source-of-truth rules + +- `docs-site/` is the public user-documentation source. +- Document current shipped or intentionally pending behavior. Do not copy claims from historical `docs/` or `devlog/` material without verifying them against current code and configuration. +- English documentation is the canonical source. Translated content must not contradict it. +- Keep commands, paths, configuration keys, defaults, branch names, and URLs synchronized with the repository. +- Do not edit generated build output. + +## Editing rules + +- Reuse the existing Astro and Starlight structure, navigation, components, and style. +- Update all directly affected pages when a user workflow changes. +- Do not add duplicated policy text when a stable canonical document can be linked instead. +- Use repository-relative links for repository files and site-relative links for documentation pages where the existing site does so. + +## Required validation + +Run: + +```bash +cd docs-site +bun install --frozen-lockfile +bun run build +``` + +Do not claim documentation validation passed unless this build completes successfully. diff --git a/docs-site/src/content/docs/contributing.md b/docs-site/src/content/docs/contributing.md index ebc1de681..9a2b41894 100644 --- a/docs-site/src/content/docs/contributing.md +++ b/docs-site/src/content/docs/contributing.md @@ -98,6 +98,12 @@ bun run release:watch # watch the newest Release workflow run `dev`; do not open feature pull requests against it. - `preview` — the prerelease train. +While the project moves its primary runtime to the Go native port, `dev2-go` +has to keep receiving everything that lands on `dev`. Nothing changes for you: +keep opening pull requests against `dev`. After a merge, a maintainer rebases +the work onto `dev2-go` and ports whatever needs a Go counterpart under `go/`, +and the item counts as finished only once both lines carry it. + Porting and rebase pull requests are welcome. Carrying a fix from one integration line to another, or rebasing a stale branch onto the current head, is normal contribution rather than noise — note the source commits in the diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 2e70c4dae..4615c0726 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -127,7 +127,10 @@ Terminal refresh failures mark the account as needing reauthentication instead o **Cooldowns (Codex pool).** Upstream `429` / quota responses set a hard cooldown from `Retry-After`, quota `reset` headers (capped), or a short default backoff. Accounts on an explicit `Retry-After` cooldown are not probed early; reset-derived cooldowns may receive a paced probe lease -so recovery can be detected without flooding the provider. +so recovery can be detected without flooding the provider. Reset-derived native-model cooldowns +also preserve known independent quota groups: `gpt-5.3-codex-spark` does not prevent the same account +from trying the shared GPT-5.6 Terra/Luna quota, while models in that shared group still protect one +another. Explicit `Retry-After` and default cooldowns always remain account-wide. **Session affinity.** Codex thread→account affinity is process-local (in-memory only; not persisted across proxy restarts). On credential failures (`401` / `403`) the account is quarantined for diff --git a/docs-site/src/content/docs/guides/video-bridge.md b/docs-site/src/content/docs/guides/video-bridge.md new file mode 100644 index 000000000..4d0c4930f --- /dev/null +++ b/docs-site/src/content/docs/guides/video-bridge.md @@ -0,0 +1,85 @@ +--- +title: Video Bridge +description: Generate videos with Grok Imagine Video through a non-OpenAI model. +--- + +## Overview + +The Video Bridge lets you use xAI's Grok Imagine Video generation through any non-OpenAI model +routed by opencodex. When enabled, a synthetic `video_gen` tool is injected into the conversation. +The model calls it like any function tool; opencodex intercepts the call, submits a video generation +job to xAI, polls until completion, and downloads the result. + +## Prerequisites + +- An `xai` provider entry with an **API key** (`ocx login xai` alone is not sufficient — the video bridge requires key auth, not OAuth) +- A non-OpenAI model as your routed provider (e.g. Anthropic Claude, Google Gemini) +- opencodex configured to route through the non-OpenAI provider + +> **⚠ Provider key required:** The video bridge only activates when the `xai` provider uses +> API key auth. Add this to your config: +> +> ```json +> { +> "providers": { +> "xai": { "adapter": "openai-chat", "apiKey": "xai-…", "authMode": "key" } +> } +> } +> ``` +> +> If you onboarded via `ocx login xai` (OAuth), the provider stays in `authMode: "oauth"` +> and the bridge silently won't activate. Set `XAI_API_KEY` in the environment **or** +> hard-code the key as shown above. + +## Configuration + +Add `videoBridgeEnabled: true` to your `images` config: + +```json +{ + "images": { + "bridgeEnabled": true, + "videoBridgeEnabled": true, + "videoBridgeModel": "grok-imagine-video", + "videoMaxRounds": 2, + "videoTimeoutMs": 300000 + } +} +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `videoBridgeEnabled` | `false` | Master switch. Must be explicitly enabled. | +| `videoBridgeModel` | `"grok-imagine-video"` | xAI video model id. | +| `videoMaxRounds` | `2` | Max video-gen rounds before forced final answer. | +| `videoTimeoutMs` | `300000` (5 min) | Per-video timeout including polling. | + +## How It Works + +1. opencodex detects a non-OpenAI routed model with `videoBridgeEnabled: true` +2. A synthetic `video_gen` function tool is injected into the conversation +3. When the model calls `video_gen`, opencodex submits a job to xAI's `/videos/generations` +4. The bridge polls the job status every 5-15 seconds, sending heartbeat messages to keep the stream alive +5. When the video is ready, it's downloaded to the artifacts directory +6. The local file path is returned to the model as a tool result + +## Supported Parameters + +The `video_gen` tool accepts: + +| Parameter | Type | Range | Description | +|-----------|------|-------|-------------| +| `prompt` | string | required | Detailed video generation prompt | +| `duration` | integer | 1-15 | Video length in seconds | +| `resolution` | string | `"480p"`, `"720p"` | Video resolution | +| `aspect_ratio` | string | 7 ratios | `16:9`, `9:16`, `1:1`, `4:3`, `3:4`, `3:2`, `2:3` | + +## Limitations + +- **xAI only**: Video generation is only available through xAI's Grok Imagine Video API +- **Asynchronous**: Video generation takes 30-120 seconds +- **Cost**: Video generation is a paid xAI feature (~$0.05/sec @480p, ~$0.07/sec @720p) +- **One video per call**: Each `video_gen` call produces one video +- **Coexists with Image Bridge**: Both bridges can be enabled simultaneously +- **Web search priority**: When a web search sidecar is active for a turn (non-`runTurn` adapter), the video bridge is skipped — the two cannot run concurrently. A `console.warn` is emitted so you can detect this in logs. +- **Timeout covers submit + poll**: The `videoTimeoutMs` budget starts before job submission, so the submit call (60 s) and subsequent polling share the same deadline. diff --git a/docs-site/src/content/docs/ja/contributing.md b/docs-site/src/content/docs/ja/contributing.md index 8194e3f54..f0727b0a9 100644 --- a/docs-site/src/content/docs/ja/contributing.md +++ b/docs-site/src/content/docs/ja/contributing.md @@ -90,6 +90,11 @@ bun run release:watch # 直近の Release ワークフロー run 出さないでください。 - `preview` — プレリリーストレイン。 +主要ランタイムを Go ネイティブポートへ移行している間、`dev` に入った変更は `dev2-go` にも +反映される必要があります。コントリビューターの手順は変わりません。これまでどおり `dev` に +PR を出してください。マージしたメンテナーがその作業を `dev2-go` にリベースし、`go/` 側で +対応が必要な部分をポートします。両方のラインに入って初めて完了とみなします。 + ポーティング PR とリベース PR を歓迎します。ある統合ラインの修正を別のラインへ運ぶこと、 古いブランチを現在の head にリベースすることは、ノイズではなく通常の貢献です。説明欄に 元のコミットを記載してください。 diff --git a/docs-site/src/content/docs/ja/reference/configuration.md b/docs-site/src/content/docs/ja/reference/configuration.md index 2cf4d114a..f98785556 100644 --- a/docs-site/src/content/docs/ja/reference/configuration.md +++ b/docs-site/src/content/docs/ja/reference/configuration.md @@ -51,6 +51,8 @@ namespaced selected id を bare id に変えます。 | `codexShimAutoRestore?` | `boolean` | `true` | 完了した外部 Codex 更新で以前にインストールした shim が置換された場合に復元します。無効にするには `false`、またはプロセスで `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` を設定します。 | | `syncResumeHistory?` | `boolean` | `true` | 戻せる Codex App 履歴互換モード。opencodex は元の Codex thread metadata をバックアップし、旧 OpenAI interactive row を `opencodex` に再マッピングし、opencodex が作成した `exec` row を App に見えるソースとして一時的に昇格します。`ocx stop` / `ocx restore` はバックアップした OpenAI row を復元し、残った opencodex user thread を OpenAI に戻し、ネイティブ Codex が `config.toml` からプロキシを削除した後でも開き続けられるようにします。オフにするには `false` に設定します。 | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth ダッシュボードが管理する ChatGPT/Codex pool アカウント metadata。secret は `codex-accounts.json` に別途置きます。 | +| `pausedCodexAccountIds?` | `string[]` | `[]` | Codex Auth で再開するまで、今後のすべての Pool 選択から除外するアカウント ID。メインを一時停止した場合は `__main__` も含みます。 | +| `codexAccountNamespaces?` | `Record` | — | 公開 model selector namespace から保存済み Codex アカウント target への任意 map。この foundation layer は map を検証・保存しますが、picker row の追加や routing の変更は行いません。 | | `activeCodexAccountId?` | `string` | — | 手動選択した pool アカウント。既存 thread affinity を消去して次のリクエストから適用し、処理中のリクエストは現在のアカウントを維持します。 | | `autoSwitchThreshold?` | `number` | `80` | 新しいセッション自動切替用の使用量百分率 threshold。既知の 5 時間、週次、30 日 quota window のうち最も高いスコアを使います。`0` なら quota 自動切替をオフにします。`quota` 戦略と `fill-first` の drain threshold にも使います。 | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Codex pool の新しいセッション rotation 戦略。**新しいセッションのみ**に適用され、既存 thread id は affinity を維持します。`quota`(既定)— アクティブアカウントが `autoSwitchThreshold` を超えたら既知 usage 最小を選択。`round-robin` — 適格アカウント間を smooth weighted で均等分散。`fill-first` — cooldown、使用不可、または(設定時)`autoSwitchThreshold` までアクティブアカウントを使い切り(未知 usage は強制切替しない)、安定ソート順で次へ。 | @@ -63,6 +65,15 @@ namespaced selected id を bare id に変えます。 | `tokenGuardian?` | `OcxTokenGuardianConfig` | off | 選択型の proactive OAuth 更新と Codex アカウント warmup ポリシー。フィールドは下で説明します。 | | `corsAllowOrigins?` | `string[]` | `[]` | CORS で追加で許可する正確な origin。loopback origin は常に許可します。 | +`codexAccountNamespaces` のキーは公開 selector です。長さは 1〜64 文字、先頭と末尾は ASCII +英数字、内部には英数字、`.`、`_`、`-` を使用でき、予約済み JavaScript object 名は拒否されます。 +値は有効な pool account id(内部 `__main__` は不可)、または Codex Desktop アカウントを示す +`"@main"` です。provider と予約済み `openai` / `combo` との衝突は大文字小文字を区別せず検査され、 +namespace 付き combo alias はその namespace prefix に selector を再利用できません。設定済み pool id +や他の selector target も selector と再利用できません。raw account id と email は +非公開のままにし、selector を公開名として使ってください。この foundation layer では map は inert で、 +model picker entry の作成、session の固定、Pool / Direct routing の変更は行いません。 + `maxConcurrentThreadsPerSession` は `config.json` キーではなく `PUT /api/v2` で使う camel-case フィールドです。`ocx v2 threads ` は対応する `max_concurrent_threads_per_session` 値を Codex の `$CODEX_HOME/config.toml` 内 `[features.multi_agent_v2]` に保存します。その table ができるように v2 を先にオンにしてください。 @@ -75,6 +86,10 @@ pool アカウントの追加と quota 更新はダッシュボードの **Codex ないアカウント metadata だけを保存し、access/refresh token は強化された Codex アカウント credential store に別途 保管します。既存 thread id はアカウント affinity を維持し、新しいセッションは `accountPoolStrategy`、quota、cooldown、health に 応じて自動ルーティングされます。 +一時停止したアカウントと quota metadata は表示されたままですが、自動切り替え、再試行/failover 選択、cooldown 復旧プローブ、手動有効化の対象外です。 +一時停止するとそのアカウントの thread affinity map も消去されます。処理中のリクエストは取得済み credential を維持しますが、以降のターンは再ルーティングされ、一時停止中のアカウントは再利用できません。 +状態は再起動後も保持され、すべてのアカウントが一時停止中なら Pool ルーティングは別のアカウントを暗黙に選ばず失敗します。 +**上限到達を一括停止** は credential がある適格アカウントだけを先に更新し、関連する quota window が今回 100% と確認できたアカウントだけを停止します。credential がないアカウントや、quota が不明、または更新に失敗したアカウントは変更しません。 **rotation 戦略**(新しいセッションのみ;bound thread は不変):`quota`(既定)— `autoSwitchThreshold` 超過時に最小 usage を選択;`round-robin` — 均等分散、`accountPoolStickyLimit`(既定 `1`、1–100)で 1 選択あたりの成功 bind 数;`fill-first` — アクティブアカウントを cooldown、再認証、または threshold まで使い切り(未知 usage は強制切替しない)後、安定ソート順で次へ。rotation は provider enforcement を回避しません — 複数アカウント利用は ToS 違反の可能性があります。 ::: diff --git a/docs-site/src/content/docs/ko/contributing.md b/docs-site/src/content/docs/ko/contributing.md index 2a808bfdd..17a2d6520 100644 --- a/docs-site/src/content/docs/ko/contributing.md +++ b/docs-site/src/content/docs/ko/contributing.md @@ -89,6 +89,11 @@ bun run release:watch # 가장 최근 Release workflow run 감시 올리지 않습니다. - `preview` — 프리릴리즈 트레인. +주 런타임을 Go 네이티브 포트로 옮기는 동안 `dev`에 들어간 변경은 `dev2-go`에도 그대로 +올라가야 합니다. 기여자가 할 일은 달라지지 않습니다. 지금처럼 `dev`로 PR을 올리면 됩니다. +머지한 메인테이너가 그 작업을 `dev2-go` 위로 리베이스하고 `go/` 쪽에 대응이 필요한 부분을 +포팅하며, 두 라인에 모두 반영되어야 작업이 끝난 것으로 봅니다. + 포팅 PR과 리베이스 PR을 환영합니다. 한 통합선의 수정을 다른 쪽으로 옮기거나, 오래된 브랜치를 현재 head 위로 리베이스하는 것은 잡음이 아니라 정상적인 기여입니다. 설명란에 출처 커밋을 적어주세요. diff --git a/docs-site/src/content/docs/ko/reference/configuration.md b/docs-site/src/content/docs/ko/reference/configuration.md index f91e3d116..c3fa6f50c 100644 --- a/docs-site/src/content/docs/ko/reference/configuration.md +++ b/docs-site/src/content/docs/ko/reference/configuration.md @@ -52,6 +52,8 @@ namespaced selected id를 bare id로 바꿉니다. | `codexShimAutoRestore?` | `boolean` | `true` | 완료된 외부 Codex 업데이트가 이전에 설치한 shim을 교체하면 자동으로 복구합니다. 끄려면 `false`로 설정하거나 프로세스에 `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`을 설정합니다. | | `syncResumeHistory?` | `boolean` | `true` | 되돌릴 수 있는 Codex App 기록 호환 모드. opencodex가 원래 Codex thread metadata를 백업하고, 예전 OpenAI interactive row를 `opencodex`로 재매핑하며, opencodex가 만든 `exec` row를 App에 보이는 source로 잠시 승격합니다. `ocx stop` / `ocx restore`는 백업한 OpenAI row를 복원하고 남은 opencodex user thread를 OpenAI로 돌려 네이티브 Codex가 `config.toml`에서 프록시를 제거한 뒤에도 이어서 열 수 있게 합니다. 끄려면 `false`로 설정합니다. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth 대시보드에서 관리하는 ChatGPT/Codex pool 계정 metadata. secret은 `codex-accounts.json`에 따로 둡니다. | +| `pausedCodexAccountIds?` | `string[]` | `[]` | Codex Auth에서 재개할 때까지 이후의 모든 Pool 선택에서 제외할 계정 ID. 메인 계정을 일시 중지하면 `__main__`도 포함됩니다. | +| `codexAccountNamespaces?` | `Record` | — | 공개 model selector namespace에서 저장된 Codex 계정 target으로 연결하는 선택적 map입니다. 이 foundation layer는 map을 검증하고 저장하지만 picker row를 추가하거나 routing을 변경하지 않습니다. | | `activeCodexAccountId?` | `string` | — | 수동으로 선택한 pool 계정. 선택 시 기존 thread affinity를 지우고 다음 요청부터 적용하며, 진행 중인 요청은 기존 계정을 유지합니다. | | `autoSwitchThreshold?` | `number` | `80` | 새 세션 자동 전환용 사용량 백분율 threshold. 알려진 5시간, 주간, 30일 quota window 중 가장 높은 점수를 씁니다. `0`이면 quota 자동 전환을 끕니다. `quota` 전략과 `fill-first` drain threshold에도 사용됩니다. | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Codex pool의 새 세션 rotation 전략. **새 세션에만** 적용되며 기존 thread id는 affinity를 유지합니다. `quota`(기본) — 활성 계정이 `autoSwitchThreshold`를 넘으면 알려진 usage가 가장 낮은 계정 선택. `round-robin` — 적격 계정 간 smooth weighted 균등 분배. `fill-first` — cooldown, 사용 불가 또는(설정 시) `autoSwitchThreshold`까지 활성 계정을 소진(알 수 없는 usage는 강제 전환하지 않음)한 뒤 안정 정렬 순으로 다음 계정. | @@ -64,6 +66,15 @@ namespaced selected id를 bare id로 바꿉니다. | `tokenGuardian?` | `OcxTokenGuardianConfig` | off | 선택형 proactive OAuth 갱신 및 Codex 계정 warmup 정책. 필드는 아래에 설명합니다. | | `corsAllowOrigins?` | `string[]` | `[]` | CORS에서 추가로 허용할 정확한 origin. loopback origin은 항상 허용합니다. | +`codexAccountNamespaces` 키는 공개 selector입니다. 길이는 1~64자이고 시작과 끝은 ASCII 영숫자여야 +하며, 내부에는 영숫자, `.`, `_`, `-`를 사용할 수 있습니다. 예약된 JavaScript object 이름은 거부됩니다. +값은 유효한 pool account id(내부 `__main__` 제외)이거나 Codex Desktop 계정을 나타내는 `"@main"`입니다. +provider 및 예약된 `openai` / `combo` 충돌은 대소문자를 구분하지 않고 검사하며, namespace가 있는 +combo alias는 selector를 namespace prefix로 재사용할 수 없습니다. 설정된 pool id와 다른 selector +target도 selector로 재사용할 수 없습니다. raw account id와 email은 비공개로 +유지하고 selector를 공개 이름으로 사용하세요. 이 foundation layer에서 map은 inert하며 model picker +entry 생성, session 고정, Pool / Direct routing 변경을 수행하지 않습니다. + `maxConcurrentThreadsPerSession`은 `config.json` 키가 아니라 `PUT /api/v2`에서 쓰는 camel-case 필드입니다. `ocx v2 threads `은 대응하는 `max_concurrent_threads_per_session` 값을 Codex의 `$CODEX_HOME/config.toml` 안 `[features.multi_agent_v2]`에 저장합니다. 해당 table이 생기도록 v2를 @@ -77,6 +88,10 @@ pool 계정 추가와 quota 갱신은 대시보드의 **Codex Auth** 페이지 아닌 계정 metadata만 저장하고, access/refresh token은 강화된 Codex 계정 credential store에 따로 보관합니다. 기존 thread id는 계정 affinity를 유지하며, 새 세션은 `accountPoolStrategy`, quota, cooldown, health에 따라 자동 라우팅됩니다. +일시 중지된 계정과 quota metadata는 계속 표시되지만 자동 전환, 재시도/failover 선택, cooldown 복구 probe, 수동 활성화에서는 제외됩니다. +일시 중지는 해당 계정의 thread affinity map도 지웁니다. 진행 중인 요청은 이미 확보한 credential을 유지하지만, 이후 턴은 다시 라우팅되며 일시 중지된 계정은 재사용할 수 없습니다. +상태는 재시작 후에도 유지되며, 모든 계정이 일시 중지되면 Pool 라우팅은 계정을 몰래 선택하지 않고 실패합니다. +**한도 도달 계정 일시 중지**는 credential이 있는 적격 계정만 먼저 새로고친 뒤 관련 quota window가 이번 응답에서 100%로 확인된 계정만 일시 중지합니다. credential이 없는 계정과 quota가 없거나 새로고침에 실패한 계정은 변경하지 않습니다. **rotation 전략**(새 세션만; bound thread는 변경 없음): `quota`(기본) — `autoSwitchThreshold` 초과 시 최저 usage 선택; `round-robin` — 균등 분배, `accountPoolStickyLimit`(기본 `1`, 1–100)로 한 선택당 diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index 01e2aa6f3..700cfd5d7 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -57,6 +57,8 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `codexShimAutoRestore?` | `boolean` | `true` | Restore a previously installed Codex shim when a completed external Codex update replaces it. Set `false`, or set `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` for a process-level opt-out. | | `syncResumeHistory?` | `boolean` | `true` | Reversible Codex App history compatibility mode. opencodex backs up original Codex thread metadata, remaps old OpenAI interactive rows to `opencodex`, and temporarily promotes opencodex-created `exec` rows to an app-visible source. `ocx stop` / `ocx restore` restore backed-up OpenAI rows and eject remaining opencodex user threads to OpenAI so native Codex can resume them after the proxy is removed from `config.toml`. Set `false` to opt out. | | `codexAccounts?` | `CodexAccount[]` | `[]` | ChatGPT/Codex pool account metadata managed by the Codex Auth dashboard. Secrets live separately in `codex-accounts.json`. | +| `pausedCodexAccountIds?` | `string[]` | `[]` | Accounts excluded from every future Pool selection until resumed in Codex Auth. Includes the main `__main__` account when paused. | +| `codexAccountNamespaces?` | `Record` | — | Optional public model-selector namespace → stored Codex account target map. This foundation layer validates and persists the map but does not add picker rows or change routing. | | `activeCodexAccountId?` | `string` | — | Manually selected Pool account. Selection clears existing thread affinity and applies to the next request; in-flight requests keep their captured account. | | `autoSwitchThreshold?` | `number` | `80` | Usage percent threshold for new-session auto-switching. The score uses the hottest known 5h, weekly, or 30d quota window. Set `0` to disable quota auto-switching. Used by the `quota` strategy and as the drain threshold for `fill-first`. | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | New-session rotation strategy for the Codex pool. Applies to **new sessions only**; existing thread ids keep affinity. `quota` — today's default: pick the lowest known usage when the active account crosses `autoSwitchThreshold`. `round-robin` — even spread across eligible accounts via smooth weighted selection. `fill-first` — keep the active account until it cools down, becomes unusable, or crosses `autoSwitchThreshold` when set (unknown usage does not force a switch), then advance to the next eligible account in stable sorted order. | @@ -70,6 +72,15 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `tokenGuardian?` | `OcxTokenGuardianConfig` | off | Optional proactive OAuth refresh and Codex-account warmup policy; fields are listed below. | | `corsAllowOrigins?` | `string[]` | `[]` | Additional exact origins allowed by CORS. Loopback origins are always allowed. | +`codexAccountNamespaces` keys are public selectors: 1–64 characters, starting and ending with an +ASCII letter or number, with letters, numbers, `.`, `_`, or `-` inside; reserved JavaScript object +names are rejected. Each value is either a valid pool-account id (never the internal `__main__`) or +`"@main"` for the Codex Desktop account. Provider and reserved `openai` / `combo` collisions are +checked case-insensitively; a namespaced combo alias cannot reuse a selector as its namespace prefix, +and configured pool ids or selector targets also cannot reuse a selector. Keep raw +account ids and emails private—the selector is the public name. In this foundation layer the map is +inert: it does not create model-picker entries, pin sessions, or alter Pool or Direct routing. + `maxConcurrentThreadsPerSession` is the camel-case field used by `PUT /api/v2`, not a `config.json` key. `ocx v2 threads ` persists the corresponding `max_concurrent_threads_per_session` value under `[features.multi_agent_v2]` in Codex's @@ -105,6 +116,14 @@ credential store. Existing thread ids keep account affinity, while new sessions `accountPoolStrategy`, quota, cooldown, and health. A pre-stream upstream **429**/**402** on one pool account is retried once on an eligible alternate account in the same request (so Codex CLI does not stall on a depleted primary while another account still has quota). +Pause keeps an account and its quota metadata visible, but excludes it from automatic switching, +retry/failover selection, cooldown recovery probes, and manual activation. Pausing also clears that +account's thread-affinity map: in-flight requests keep their captured credentials, but subsequent +turns are re-routed and cannot reuse the paused account. The exclusion survives +restarts; if every account is paused, Pool routing fails instead of silently selecting one. +**Pause exhausted** first refreshes eligible accounts that have credentials available and pauses +only those whose relevant quota window is freshly confirmed at 100%; accounts without credentials +and unknown or failed quota refreshes are left unchanged. ::: **Rotation strategies** (new sessions only; bound threads are unchanged): diff --git a/docs-site/src/content/docs/ru/contributing.md b/docs-site/src/content/docs/ru/contributing.md index 8f21c6303..3294e6348 100644 --- a/docs-site/src/content/docs/ru/contributing.md +++ b/docs-site/src/content/docs/ru/contributing.md @@ -91,6 +91,12 @@ bun run release:watch # наблюдение за последни открывайте сюда PR с функциональностью. - `preview` — ветка предрелизов. +Пока проект переводит основной рантайм на нативный порт Go, всё, что попадает в `dev`, +должно попадать и в `dev2-go`. Для контрибьюторов ничего не меняется: продолжайте +открывать pull request'ы в `dev`. После merge мейнтейнер выполняет rebase этой работы на +`dev2-go` и переносит то, чему нужен аналог в `go/`. Задача считается завершённой, только +когда изменение есть в обеих линиях. + Pull request'ы с портированием и ребейзом приветствуются. Перенос исправления между линиями интеграции или ребейз устаревшей ветки на текущий head — это обычный вклад, а не шум. Укажите исходные коммиты в описании. diff --git a/docs-site/src/content/docs/ru/reference/configuration.md b/docs-site/src/content/docs/ru/reference/configuration.md index c6b674e49..d90d44766 100644 --- a/docs-site/src/content/docs/ru/reference/configuration.md +++ b/docs-site/src/content/docs/ru/reference/configuration.md @@ -56,6 +56,8 @@ opencodex настраивается файлом `~/.opencodex/config.json`. Е | `codexShimAutoRestore?` | `boolean` | `true` | Восстанавливает ранее установленный shim после того, как завершённое внешнее обновление Codex заменило его. Для отключения задайте `false` или установите процессу `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`. | | `syncResumeHistory?` | `boolean` | `true` | Обратимый режим совместимости истории Codex App. opencodex резервирует исходные метаданные потоков Codex, переназначает старые интерактивные строки OpenAI на `opencodex` и временно повышает созданные opencodex строки `exec` до видимого в приложении источника. `ocx stop` / `ocx restore` восстанавливают зарезервированные строки OpenAI и возвращают оставшиеся пользовательские потоки opencodex обратно к OpenAI, чтобы нативный Codex мог возобновлять их после удаления прокси из `config.toml`. Установите `false`, чтобы отказаться. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Метаданные аккаунтов пула ChatGPT/Codex, управляемые дашбордом Codex Auth. Секреты хранятся отдельно в `codex-accounts.json`. | +| `pausedCodexAccountIds?` | `string[]` | `[]` | ID аккаунтов, исключённых из всех будущих выборов Pool до возобновления в Codex Auth. При паузе основного аккаунта включает `__main__`. | +| `codexAccountNamespaces?` | `Record` | — | Необязательная map публичного namespace селектора модели на сохранённую цель аккаунта Codex. Этот foundation layer проверяет и сохраняет map, но не добавляет строки picker и не меняет routing. | | `activeCodexAccountId?` | `string` | — | Вручную выбранный аккаунт пула. Выбор очищает существующие привязки потоков и действует со следующего запроса; выполняющиеся запросы сохраняют захваченный аккаунт. | | `autoSwitchThreshold?` | `number` | `80` | Порог процента использования для автопереключения новых сессий. Оценка использует самое «горячее» из известных окон квоты — 5-часовое, недельное или 30-дневное. Установите `0`, чтобы отключить автопереключение по квоте. Используется стратегией `quota` и как порог исчерпания для `fill-first`. | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Стратегия ротации новых сессий для пула Codex. Применяется **только к новым сессиям**; существующие id потоков сохраняют affinity. `quota` (по умолчанию) — выбор наименьшего известного usage, когда активный аккаунт превышает `autoSwitchThreshold`. `round-robin` — равномерное распределение между подходящими аккаунтами через smooth weighted selection. `fill-first` — использовать активный аккаунт до cooldown, недоступности или (если задано) `autoSwitchThreshold` (неизвестный usage не принуждает к переключению), затем переход к следующему подходящему аккаунту в стабильном отсортированном порядке. | @@ -68,6 +70,16 @@ opencodex настраивается файлом `~/.opencodex/config.json`. Е | `tokenGuardian?` | `OcxTokenGuardianConfig` | выкл. | Необязательная политика проактивного обновления OAuth и прогрева аккаунтов Codex; поля перечислены ниже. | | `corsAllowOrigins?` | `string[]` | `[]` | Дополнительные точные origin, разрешённые CORS. Loopback-origin разрешены всегда. | +Ключи `codexAccountNamespaces` — публичные селекторы длиной 1–64 символа. Они должны начинаться и +заканчиваться ASCII-буквой или цифрой; внутри разрешены буквы, цифры, `.`, `_` и `-`. Зарезервированные +имена объектов JavaScript запрещены. Значение — допустимый id аккаунта пула (кроме внутреннего `__main__`) +либо `"@main"` для аккаунта Codex Desktop. Коллизии с provider и зарезервированными `openai` / `combo` +проверяются без учёта регистра; namespace-префикс namespaced combo alias не может повторять селектор. +Настроенные id пула и цели других селекторов также нельзя повторно использовать как селектор. Сохраняйте +raw id аккаунтов и email приватными, а селектор используйте как публичное имя. +В этом foundation layer map инертна: она не создаёт записи model picker, не закрепляет сессии и не меняет +маршрутизацию Pool / Direct. + `maxConcurrentThreadsPerSession` — это camelCase-поле, используемое `PUT /api/v2`, а не ключ `config.json`. `ocx v2 threads ` сохраняет соответствующее значение `max_concurrent_threads_per_session` в `[features.multi_agent_v2]` в @@ -83,6 +95,12 @@ opencodex настраивается файлом `~/.opencodex/config.json`. Е защищённом хранилище учётных данных аккаунтов Codex. Существующие id потоков сохраняют привязку к аккаунту; новые сессии маршрутизируются по `accountPoolStrategy`, квоте, cooldown и работоспособности. +Приостановленный аккаунт и его метаданные квоты остаются видимыми, но исключаются из автоматического переключения, +повторов/failover, проб восстановления cooldown и ручной активации. Пауза также очищает карту affinity потоков +этого аккаунта: выполняющиеся запросы сохраняют захваченные учётные данные, но последующие ходы +перемаршрутизируются и не могут повторно использовать приостановленный аккаунт. Состояние сохраняется после перезапуска; +если приостановлены все аккаунты, маршрутизация Pool завершается ошибкой, а не выбирает аккаунт скрытно. +**Приостановить исчерпанные** сначала обновляет только подходящие аккаунты с доступными учётными данными и приостанавливает только те, для которых актуальное окно квоты в этом ответе подтверждено на уровне 100%. Аккаунты без учётных данных, с неизвестной квотой или неудачным обновлением не меняются. **Стратегии ротации** (только новые сессии; привязанные потоки не меняются): `quota` (по умолчанию) — выбор наименьшего usage при превышении `autoSwitchThreshold`; `round-robin` — равномерное diff --git a/docs-site/src/content/docs/zh-cn/contributing.md b/docs-site/src/content/docs/zh-cn/contributing.md index bd7723325..19c5c2154 100644 --- a/docs-site/src/content/docs/zh-cn/contributing.md +++ b/docs-site/src/content/docs/zh-cn/contributing.md @@ -84,6 +84,10 @@ bun run release:watch # 观察最新的 Release workflow run - `main` — 仅用于发布。只有维护者从 `dev` 提升时才会变动,请勿直接提功能 PR。 - `preview` — 预发布通道。 +在主运行时迁移到 Go 原生移植期间,进入 `dev` 的改动同样要进入 `dev2-go`。贡献者的流程 +不变:照常向 `dev` 提 pull request。合并之后,维护者会把这份工作变基到 `dev2-go`,并把 +需要 Go 对应实现的部分移植到 `go/`。只有两条线都带上该改动,这件事才算完成。 + 欢迎移植 PR 和变基 PR。把一条集成线上的修复带到另一条线,或把陈旧分支变基到当前 head, 都是正常的贡献而非噪音。请在描述中注明来源提交。 diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration.md b/docs-site/src/content/docs/zh-cn/reference/configuration.md index da12eab6d..0c0e2a5e5 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration.md @@ -50,6 +50,8 @@ no-replace 方式创建 `config.json.pre-openai-tiers-v2.bak`,并把已知旧 | `codexShimAutoRestore?` | `boolean` | `true` | 已完成的外部 Codex 更新替换此前安装的 shim 时自动恢复。若要关闭,请设为 `false`,或为进程设置 `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`。 | | `syncResumeHistory?` | `boolean` | `true` | 可逆的 Codex App 历史兼容模式。opencodex 会备份原始 Codex thread metadata,把旧 OpenAI interactive row 重映射到 `opencodex`,并暂时把 opencodex 创建的 `exec` row 提升成 App 可见 source。`ocx stop` / `ocx restore` 会恢复已备份的 OpenAI row,并把剩余 opencodex user thread 转回 OpenAI,使原生 Codex 在从 `config.toml` 移除代理后仍能继续这些 thread。设为 `false` 可退出该模式。 | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth 仪表盘管理的 ChatGPT/Codex pool account metadata。secret 单独存放在 `codex-accounts.json`。 | +| `pausedCodexAccountIds?` | `string[]` | `[]` | 在 Codex Auth 中恢复前,不参与任何后续 Pool 选择的账号 ID。暂停主账号时也包含 `__main__`。 | +| `codexAccountNamespaces?` | `Record` | — | 可选的公开 model selector namespace 到已保存 Codex account target 的映射。此 foundation layer 只验证并持久化该映射,不会添加 picker row 或改变 routing。 | | `activeCodexAccountId?` | `string` | — | 手动选择的 pool account。选择时清除已有 thread affinity,并从下一次请求开始生效;进行中的请求保留原账号。 | | `autoSwitchThreshold?` | `number` | `80` | 新 session 自动切换的 usage 百分比 threshold。分数取已知 5 小时、周或 30 天 quota window 中最高的一项。设为 `0` 可禁用 quota 自动切换。`quota` 策略和 `fill-first` 的耗尽 threshold 都会用到。 | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Codex pool 的新 session 轮换策略。仅适用于**新 session**;已有 thread id 保留 affinity。`quota`(默认):活跃账号超过 `autoSwitchThreshold` 时选已知 usage 最低者。`round-robin`:在合格账号间平滑加权均分。`fill-first`:持续使用活跃账号,直到 cooldown、不可用或(如已设置)超过 `autoSwitchThreshold`(未知 usage 不会强制切换),再按稳定排序进入下一个合格账号。 | @@ -62,6 +64,14 @@ no-replace 方式创建 `config.json.pre-openai-tiers-v2.bak`,并把已知旧 | `tokenGuardian?` | `OcxTokenGuardianConfig` | 关闭 | 可选的 proactive OAuth 刷新和 Codex account warmup 策略;字段见下文。 | | `corsAllowOrigins?` | `string[]` | `[]` | CORS 额外允许的精确 origin。loopback origin 始终允许。 | +`codexAccountNamespaces` 的 key 是公开 selector:长度为 1–64 个字符,首尾必须是 ASCII 字母或数字, +中间可使用字母、数字、`.`、`_` 或 `-`;保留的 JavaScript object 名称会被拒绝。value 必须是有效的 +pool account id(不能是内部 `__main__`),或用 `"@main"` 表示 Codex Desktop 账号。与 provider 及 +保留的 `openai` / `combo` 冲突时不区分大小写;带 namespace 的 combo alias 不能把 selector 复用为 +其 namespace prefix,已配置的 pool id 和其他 selector target 也不能复用为 selector。raw account id +与 email 应保持私密,selector 才是公开名称。在此 foundation layer 中, +该映射是 inert 的:不会创建 model picker entry、固定 session,也不会改变 Pool / Direct routing。 + `maxConcurrentThreadsPerSession` 是 `PUT /api/v2` 使用的 camel-case 字段,不是 `config.json` key。 `ocx v2 threads ` 会把对应的 `max_concurrent_threads_per_session` 值写入 Codex `$CODEX_HOME/config.toml` 的 `[features.multi_agent_v2]` 下;请先启用 v2,确保该 table 存在。 @@ -73,6 +83,10 @@ no-replace 方式创建 `config.json.pre-openai-tiers-v2.bak`,并把已知旧 请在仪表盘 **Codex Auth** 页面添加 pool account 并刷新 quota。配置只保存非 secret account metadata;access/refresh token 存放在加固的 Codex account credential store 中。已有 thread id 会 保留 account affinity;新 session 按 `accountPoolStrategy`、quota、cooldown 和 health 自动路由。 +暂停后仍会显示账号及其 quota metadata,但不会参与自动切换、重试/failover 选择、cooldown 恢复探测或手动激活。 +暂停还会清除该账号的 thread affinity map:进行中的请求保留已捕获的 credential,但后续 turn 会重新路由,无法再使用已暂停账号。 +暂停状态会跨重启保留;如果所有账号均已暂停,Pool 路由会明确失败,而不会暗中选择某个账号。 +**暂停已达上限账号** 会先刷新有 credential 的合格账号,只暂停相关 quota window 本次明确返回 100% 的账号;无 credential、未知额度或刷新失败的账号保持不变。 **轮换策略**(仅新 session;已绑定 thread 不变):`quota`(默认)— 活跃账号 usage 超过 `autoSwitchThreshold` 时选最低者;`round-robin` — 均分,`accountPoolStickyLimit`(默认 `1`, diff --git a/gui/AGENTS.md b/gui/AGENTS.md index 4216f1543..61459e58b 100644 --- a/gui/AGENTS.md +++ b/gui/AGENTS.md @@ -1,5 +1,14 @@ # OpenCodex GUI — agent rules +This file applies to `gui/` and inherits the repository-wide rules in `/AGENTS.md`. + +## Ownership and generated output + +- `gui/` is the React + Vite dashboard. +- `gui/dist/` is generated packaged output. Do not edit it by hand. +- Use the existing component, state, routing, styling, and data-access patterns before introducing a new abstraction. +- Keep dashboard behavior aligned with the management API and provider configuration model. + ## Text and i18n - **No hardcoded visible UI text** in `src/pages`, `src/components`, `src/App.tsx`, or `src/ui.tsx`. @@ -19,7 +28,33 @@ - Keep **code comments** (including shell `# …` comments in samples). Never strip them to “satisfy” i18n. - Run `bun run lint:i18n` after UI copy changes; fix real violations before committing. If a hit is technical, extend the allowlist or put the string in `
`/`` — do not invent nonsense translation keys.
 
+## Implementation rules
+
+- Preserve accessibility: keyboard operation, labels, focus behavior, semantic controls, and readable validation errors.
+- Do not introduce a dependency for behavior already provided by the current stack or a small local implementation.
+- Dependency changes require explicit security review.
+- Update `docs-site/` when dashboard behavior, setup, or configuration changes for users.
 
 ## Failure mode
 
 Hardcoding English (or German) in JSX to “fix” a bad translation is **not** allowed. Add or fix the key in all locale files instead.
+
+## Required validation
+
+Run all of the following for every functional `gui/` change:
+
+```bash
+cd gui
+bun test tests
+bun run lint
+bun run build
+```
+
+After any UI-copy or locale change, also run:
+
+```bash
+cd gui
+bun run lint:i18n
+```
+
+Run the repository-level checks required by any non-GUI files changed in the same work.
diff --git a/gui/src/api.ts b/gui/src/api.ts
index fd6a777bb..664cde6a9 100644
--- a/gui/src/api.ts
+++ b/gui/src/api.ts
@@ -1,5 +1,12 @@
 let installed = false;
+/** Shared 401 refresh gate — concurrent waiters join one prompt / token resolution. */
 let promptInFlight: Promise | null = null;
+/**
+ * After the user cancels (or submits blank) once, suppress further prompts for this page
+ * lifetime so a staggered 401 fan-out does not reopen the dialog N times (#647 / Codex).
+ * A full reload clears module state and allows prompting again.
+ */
+let promptCancelled = false;
 
 function needsApiAuth(input: RequestInfo | URL): boolean {
   try {
@@ -31,6 +38,11 @@ function clearToken(): void {
   memoryToken = null;
 }
 
+/** Clear memory only when it still holds `expected` (avoid wiping a newer concurrent store). */
+function clearTokenIfCurrent(expected: string | null): void {
+  if (expected != null && readToken() === expected) clearToken();
+}
+
 function clearLegacySessionToken(): void {
   try {
     sessionStorage.removeItem(LEGACY_TOKEN_KEY);
@@ -46,11 +58,31 @@ function withToken(input: RequestInfo | URL, init: RequestInit | undefined, toke
   return [input, { ...init, headers }];
 }
 
-async function promptForToken(): Promise {
+/**
+ * Resolve a token after a 401. Concurrent callers share one in-flight resolution so a dashboard
+ * fan-out does not open one window.prompt per /api request (#647). Re-reads memoryToken before
+ * prompting so waiters that wake after another request already stored a token do not re-prompt.
+ */
+async function resolveTokenAfter401(failedToken: string | null): Promise {
+  if (promptCancelled) return null;
   if (promptInFlight) return promptInFlight;
-  promptInFlight = Promise.resolve()
-    .then(() => window.prompt("OpenCodex API token")?.trim() || null)
-    .finally(() => { promptInFlight = null; });
+
+  promptInFlight = (async () => {
+    if (promptCancelled) return null;
+    const current = readToken();
+    if (current && current !== failedToken) return current;
+
+    const prompted = window.prompt("OpenCodex API token")?.trim() || null;
+    if (prompted) {
+      storeToken(prompted);
+      return prompted;
+    }
+    promptCancelled = true;
+    return null;
+  })().finally(() => {
+    promptInFlight = null;
+  });
+
   return promptInFlight;
 }
 
@@ -68,14 +100,23 @@ export function installApiAuthFetch(): void {
     const response = await originalFetch(firstInput, firstInit);
     if (response.status !== 401) return response;
 
-    if (token) clearToken();
-    const nextToken = await promptForToken();
+    // Another request may have stored a token while this one was in flight (or while prompt blocked).
+    const refreshed = readToken();
+    if (refreshed && refreshed !== token) {
+      const [retryInput, retryInit] = withToken(input, init, refreshed);
+      const retry = await originalFetch(retryInput, retryInit);
+      if (retry.status !== 401) return retry;
+      clearTokenIfCurrent(refreshed);
+    } else {
+      clearTokenIfCurrent(token);
+    }
+
+    const nextToken = await resolveTokenAfter401(token);
     if (!nextToken) return response;
 
-    storeToken(nextToken);
     const [retryInput, retryInit] = withToken(input, init, nextToken);
     const retry = await originalFetch(retryInput, retryInit);
-    if (retry.status === 401) clearToken();
+    if (retry.status === 401) clearTokenIfCurrent(nextToken);
     return retry;
   };
 }
@@ -85,4 +126,5 @@ export function resetApiAuthFetchForTests(): void {
   installed = false;
   memoryToken = null;
   promptInFlight = null;
+  promptCancelled = false;
 }
diff --git a/gui/src/components/CodexAccountPool.tsx b/gui/src/components/CodexAccountPool.tsx
index 2a52c310d..c5a212e21 100644
--- a/gui/src/components/CodexAccountPool.tsx
+++ b/gui/src/components/CodexAccountPool.tsx
@@ -29,7 +29,7 @@ const DOCTOR_CMD = "ocx doctor";
  * Auth page (WP060). `accountModeState` arrives as a prop (the parent owns the
  * /api/config fetch); `banner` is an optional slot rendered above the main card
  * (the Codex Auth page passes its mode banner); `embedded` (WP090) omits page
- * chrome — currently a no-op stub reserved for the Providers workspace.
+ * title chrome while retaining the shared account actions in the Providers workspace.
  */
 export default function CodexAccountPool({ apiBase, accountModeState = null, banner = null, embedded = false, onActiveNeedsReauthChange, controller: injectedController }: {
   apiBase: string;
@@ -55,7 +55,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban
   // but stays inert (no load, no polling) whenever a shared controller was injected.
   const ownController = useCodexAccountPool(apiBase, !injectedController);
   const controller = injectedController ?? ownController;
-  const { accounts, activeId, loadState, switchingId, load } = controller;
+  const { accounts, activeId, loadState, switchingId, pauseUpdatingId, pausingExhausted, load } = controller;
   const [confirm, setConfirm] = useState(null);
   const [showAdd, setShowAdd] = useState(false);
   const [reauthId, setReauthId] = useState(null);
@@ -103,7 +103,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban
   const activePoolAccount = activeId && activeId !== "__main__"
     ? accounts.find(a => a.id === activeId)
     : null;
-  const activePoolNeedsReauth = accountNeedsReauth(activePoolAccount);
+  const activePoolNeedsReauth = !activePoolAccount?.paused && accountNeedsReauth(activePoolAccount);
 
   useEffect(() => {
     onActiveNeedsReauthChange?.(activePoolNeedsReauth);
@@ -156,6 +156,20 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban
     setToast(t(result.ok ? "prov.aliasSaved" : "prov.aliasSaveFailed"));
   };
 
+  const togglePaused = async (account: CodexAccountEntry) => {
+    const paused = !account.paused;
+    const result = await controller.setAccountPaused(account.id, paused);
+    if (!result.ok && result.reason === "busy") return;
+    setConfirm(current => current?.id === account.id ? null : current);
+    setToastError(!result.ok);
+    setToast(t(result.ok
+      ? paused ? "codexAuth.pauseSucceeded" : "codexAuth.resumeSucceeded"
+      : paused ? "codexAuth.pauseFailed" : "codexAuth.resumeFailed", {
+      email: account.alias ?? account.email,
+    }));
+    setTimeout(() => setToast(""), 5000);
+  };
+
   const remove = async (id: string) => {
     const label = accounts.find(account => account.id === id)?.email ?? t("pws.accountOrdinal", { count: "1" });
     if (!window.confirm(t("codexAuth.removeConfirm", { id: label }))) return;
@@ -178,6 +192,18 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban
     }
   };
 
+  const pauseExhausted = async () => {
+    const result = await controller.pauseExhaustedAccounts();
+    if (!result.ok && result.reason === "busy") return;
+    setToastError(!result.ok);
+    setToast(result.ok
+      ? result.pausedCount > 0
+        ? t("codexAuth.pauseExhaustedSucceeded", { count: String(result.pausedCount) })
+        : t("codexAuth.pauseExhaustedNone")
+      : t("codexAuth.pauseExhaustedFailed"));
+    setTimeout(() => setToast(""), 5000);
+  };
+
   const openResetPopup = async (account: CodexAccountEntry) => {
     setResetPopup(account);
     setResetConfirm(false);
@@ -216,7 +242,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban
 
   const main = accounts.find(a => a.isMain);
   const pool = accounts.filter(a => !a.isMain);
-  const isMainActive = !activeId || activeId === "__main__";
+  const isMainActive = !main?.paused && (!activeId || activeId === "__main__");
   const switchActionLabel = t(accountModeState === "direct" ? "codexAuth.prepareForPool" : "codexAuth.setAsNext");
 
   return (
@@ -225,7 +251,10 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban
         t={t}
         embedded={embedded}
         refreshingQuota={refreshingQuota}
+        pausingExhausted={pausingExhausted}
+        pauseBusy={pauseUpdatingId !== null || pausingExhausted}
         onRefresh={() => { void refreshQuotas(); }}
+        onPauseExhausted={() => { void pauseExhausted(); }}
       />
 
       {toast && {toast}}
@@ -247,6 +276,9 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban
         threshold={autoSwitch.threshold ?? 0}
         switchActionLabel={switchActionLabel}
         onSwitch={setConfirm}
+        onTogglePause={togglePaused}
+        pauseUpdatingId={pauseUpdatingId}
+        pauseBusy={pauseUpdatingId !== null || pausingExhausted}
         onOpenReset={openResetPopup}
         onCopyDoctor={copyDoctor}
         doctorCopyOutcomeFor={doctorCopy.outcomeFor}
@@ -274,6 +306,9 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban
         threshold={autoSwitch.threshold ?? 0}
         onOpenReset={openResetPopup}
         onSwitch={setConfirm}
+        onTogglePause={togglePaused}
+        pauseUpdatingId={pauseUpdatingId}
+        pauseBusy={pauseUpdatingId !== null || pausingExhausted}
         onReauth={openReauth}
         onEditAlias={editAlias}
         onRemove={remove}
diff --git a/gui/src/components/codex-account-pool-cards.tsx b/gui/src/components/codex-account-pool-cards.tsx
index 478b0e8a2..605f3a458 100644
--- a/gui/src/components/codex-account-pool-cards.tsx
+++ b/gui/src/components/codex-account-pool-cards.tsx
@@ -1,5 +1,5 @@
 import { useT } from "../i18n/shared";
-import { IconAlert, IconX } from "../icons";
+import { IconAlert, IconPause, IconPlay, IconX } from "../icons";
 import { displayAccountId } from "../lib/privacy";
 import type { CodexAccountEntry } from "./codex-account-pool-types";
 import type { CodexAccountModeState } from "../codex-multi-state";
@@ -23,6 +23,9 @@ export function CodexAccountPoolCards({
   threshold,
   onOpenReset,
   onSwitch,
+  onTogglePause,
+  pauseUpdatingId,
+  pauseBusy,
   onReauth,
   onEditAlias,
   onRemove,
@@ -36,6 +39,9 @@ export function CodexAccountPoolCards({
   threshold: number;
   onOpenReset: (account: CodexAccountEntry) => void;
   onSwitch: (account: CodexAccountEntry) => void;
+  onTogglePause: (account: CodexAccountEntry) => void;
+  pauseUpdatingId: string | null;
+  pauseBusy: boolean;
   onReauth: (id: string) => void;
   onEditAlias: (account: CodexAccountEntry) => void;
   onRemove: (id: string) => void;
@@ -43,7 +49,7 @@ export function CodexAccountPoolCards({
   doctorCopyOutcomeFor?: (accountId: string) => "copied" | "unavailable" | null;
 }) {
   const t = useT();
-  const isNext = (id: string) => activeId === id;
+  const isNext = (account: CodexAccountEntry) => !account.paused && activeId === account.id;
 
   return (
     <>
@@ -54,24 +60,25 @@ export function CodexAccountPoolCards({
         const healthLabel = formatOAuthHealthLabel(t, a.health);
         const healthSummary = formatOAuthHealthSummary(t, "codex", a.id, a.health);
         return (
-        
+
- + {a.alias ?? a.email} {a.plan && {a.plan}} + {a.paused && {t("codexAuth.paused")}} onOpenReset(a)} /> {healthLabel && ( {healthLabel} )} {showReauth && !healthLabel && {t("codexAuth.needsReauth")}} - {isNext(a.id) && !showReauth && !inCooldown && ( + {isNext(a) && !showReauth && !inCooldown && ( {t(accountModeState === "direct" ? "codexAuth.poolPrepared" : "codexAuth.nextSession")} )} - {!isNext(a.id) && !showReauth && !inCooldown && ( + {!a.paused && !isNext(a) && !showReauth && !inCooldown && ( @@ -86,6 +93,15 @@ export function CodexAccountPoolCards({ {doctorCopyButtonLabel(t, doctorCopyOutcomeFor?.(a.id))} )} + @@ -103,6 +119,7 @@ export function CodexAccountPoolCards({ {healthSummary && (
{healthSummary}
)} + {a.paused &&
{t("codexAuth.pausedHint")}
} {inCooldown && (
{t("pws.healthCooldownHint")}
)} diff --git a/gui/src/components/codex-account-pool-helpers.tsx b/gui/src/components/codex-account-pool-helpers.tsx index 56c439b48..9c67d1ce8 100644 --- a/gui/src/components/codex-account-pool-helpers.tsx +++ b/gui/src/components/codex-account-pool-helpers.tsx @@ -1,10 +1,10 @@ -import type { TFn } from "../i18n/shared"; +import type { Locale, TFn } from "../i18n/shared"; import { IconTicket } from "../icons"; import type { CodexAccountEntry } from "./codex-account-pool-types"; -import { daysUntil, formatCreditDate } from "./codex-account-pool-utils"; +import { daysUntil, formatCreditDate, formatCreditDateTime } from "./codex-account-pool-utils"; -export function CodexCreditItem({ index, grantedAt, expiresAt, isNext, t }: { - index: number; grantedAt: string; expiresAt: string; isNext: boolean; t: TFn; +export function CodexCreditItem({ index, grantedAt, expiresAt, isNext, locale, t }: { + index: number; grantedAt: string; expiresAt: string; isNext: boolean; locale: Locale; t: TFn; }) { const days = daysUntil(expiresAt); const urgent = days <= 7; @@ -18,8 +18,8 @@ export function CodexCreditItem({ index, grantedAt, expiresAt, isNext, t }: { {isNext && {t("codexAuth.creditNextBadge")}}
- {t("codexAuth.creditGranted", { date: formatCreditDate(grantedAt) })} - {t("codexAuth.creditExpires", { date: formatCreditDate(expiresAt), days: String(days) })} + {t("codexAuth.creditGranted", { date: formatCreditDate(grantedAt, locale) })} + {t("codexAuth.creditExpires", { date: formatCreditDateTime(expiresAt, locale), days: String(days) })}
); diff --git a/gui/src/components/codex-account-pool-main-card.tsx b/gui/src/components/codex-account-pool-main-card.tsx index 7f205a4da..97fd176e5 100644 --- a/gui/src/components/codex-account-pool-main-card.tsx +++ b/gui/src/components/codex-account-pool-main-card.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from "react"; -import { IconLock, IconRefresh } from "../icons"; +import { IconLock, IconPause, IconPlay, IconRefresh } from "../icons"; import QuotaBars from "./QuotaBars"; import { CodexTicketBadge } from "./codex-account-pool-helpers"; import type { CodexAccountEntry } from "./codex-account-pool-types"; @@ -23,6 +23,9 @@ export function CodexAccountPoolMainCard({ threshold, switchActionLabel, onSwitch, + onTogglePause, + pauseUpdatingId, + pauseBusy, onOpenReset, onCopyDoctor, doctorCopyOutcomeFor, @@ -34,6 +37,9 @@ export function CodexAccountPoolMainCard({ threshold: number; switchActionLabel: string; onSwitch: (entry: CodexAccountEntry) => void; + onTogglePause: (entry: CodexAccountEntry) => void; + pauseUpdatingId: string | null; + pauseBusy: boolean; onOpenReset: (account: CodexAccountEntry) => void; onCopyDoctor?: (accountId: string) => void; doctorCopyOutcomeFor?: (accountId: string) => "copied" | "unavailable" | null; @@ -45,6 +51,7 @@ export function CodexAccountPoolMainCard({ email: main?.email || mainFallbackLabel, plan: main?.plan, isMain: true, + paused: main?.paused ?? false, hasCredential: true, quota: main?.quota ?? null, }; @@ -62,17 +69,20 @@ export function CodexAccountPoolMainCard({ {t("codexAuth.mainAccount")} {main && onOpenReset({ ...main, id: "__main__" } as CodexAccountEntry)} />} + {main?.paused && {t("codexAuth.paused")}} {healthLabel && ( {healthLabel} )} {showReauth && !healthLabel && {t("codexAuth.needsReauth")}} - - {isMainActive - ? t(accountModeState === "direct" ? "codexAuth.poolPrepared" : "codexAuth.nextSession") - : t("codexAuth.current")} - + {!main?.paused && ( + + {isMainActive + ? t(accountModeState === "direct" ? "codexAuth.poolPrepared" : "codexAuth.nextSession") + : t("codexAuth.current")} + + )} - {!isMainActive && !showReauth && !inCooldown && ( + {!main?.paused && !isMainActive && !showReauth && !inCooldown && ( @@ -82,12 +92,24 @@ export function CodexAccountPoolMainCard({ {doctorCopyButtonLabel(t, doctorCopyOutcomeFor?.(mainId))} )} + {main && ( + + )} {t("codexAuth.appLogin")}
{main?.email || t("codexAuth.appLogin")}{main?.plan ? ` · ${main.plan}` : ""}
{healthSummary && (
{healthSummary}
)} + {main?.paused &&
{t("codexAuth.pausedHint")}
} {inCooldown && (
{t("pws.healthCooldownHint")}
)} @@ -102,20 +124,43 @@ export function CodexAccountPoolPageHead({ t, embedded, refreshingQuota, + pausingExhausted, + pauseBusy, onRefresh, + onPauseExhausted, }: { t: TFn; embedded: boolean; refreshingQuota: boolean; + pausingExhausted: boolean; + pauseBusy?: boolean; onRefresh: () => void; + onPauseExhausted: () => void; }) { - if (embedded) return null; return ( -
-

{t("nav.codexAuth")}

- +
+ {!embedded &&

{t("nav.codexAuth")}

} +
+ + +
); } diff --git a/gui/src/components/codex-account-pool-utils.ts b/gui/src/components/codex-account-pool-utils.ts index 52d091eba..4462199cc 100644 --- a/gui/src/components/codex-account-pool-utils.ts +++ b/gui/src/components/codex-account-pool-utils.ts @@ -1,7 +1,14 @@ -import { formatCreditDate as formatCreditDateIntl } from "../intl-formatters"; +import { + formatCreditDate as formatCreditDateIntl, + formatCreditDateTime as formatCreditDateTimeIntl, +} from "../intl-formatters"; -export function formatCreditDate(iso: string): string { - return formatCreditDateIntl(iso); +export function formatCreditDate(iso: string, locale?: string): string { + return formatCreditDateIntl(iso, locale); +} + +export function formatCreditDateTime(iso: string, locale?: string): string { + return formatCreditDateTimeIntl(iso, locale); } export function daysUntil(iso: string): number { diff --git a/gui/src/components/codex-account-reset-modal.tsx b/gui/src/components/codex-account-reset-modal.tsx index 102c3e9ba..cc518029b 100644 --- a/gui/src/components/codex-account-reset-modal.tsx +++ b/gui/src/components/codex-account-reset-modal.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef } from "react"; -import { useT } from "../i18n/shared"; +import { useI18n } from "../i18n/shared"; import { IconAlert, IconTicket } from "../icons"; import type { CodexAccountEntry } from "./codex-account-pool-types"; import { CodexCreditItem } from "./codex-account-pool-helpers"; @@ -26,7 +26,7 @@ export function CodexAccountResetModal({ onCancelConfirm: () => void; onRedeem: () => void; }) { - const t = useT(); + const { locale, t } = useI18n(); const dialogRef = useRef(null); useEffect(() => { @@ -61,7 +61,7 @@ export function CodexAccountResetModal({ {creditDetails && creditDetails.length > 0 && (
{creditDetails.map((c, i) => ( - + ))}
)} @@ -87,7 +87,7 @@ export function CodexAccountResetModal({

{t("codexAuth.confirmResetDesc", { count: String(resetPopup.quota?.resetCredits ?? 0) })}

{creditDetails && creditDetails[0] && (

- {t("codexAuth.confirmWhichCredit", { date: formatCreditDate(creditDetails[0].granted_at) })} + {t("codexAuth.confirmWhichCredit", { date: formatCreditDate(creditDetails[0].granted_at, locale) })}

)}

{t("codexAuth.irreversible")}

diff --git a/gui/src/components/use-add-provider-oauth.ts b/gui/src/components/use-add-provider-oauth.ts index 95e6b394a..dfcceb4b3 100644 --- a/gui/src/components/use-add-provider-oauth.ts +++ b/gui/src/components/use-add-provider-oauth.ts @@ -2,6 +2,8 @@ import { useCallback } from "react"; import type { TFn } from "../i18n/shared"; import { readJsonIfOk } from "../fetch-json"; +export const OAUTH_LOGIN_POLL_INTERVAL_MS = 2_000; + export function useAddProviderOAuth({ apiBase, t, @@ -52,17 +54,17 @@ export function useAddProviderOAuth({ if (data.url) { setOauthUrl(data.url, providerId); setOauthMsg(t("modal.waitingLogin")); } else { setOauthMsg(data.instructions || t("modal.loggingIn")); } for (let i = 0; i < 100; i++) { - await new Promise(r => setTimeout(r, 2000)); + await new Promise(r => setTimeout(r, OAUTH_LOGIN_POLL_INTERVAL_MS)); if (!aliveRef.current) return; const sRes = await fetch(`${apiBase}/api/oauth/status?provider=${providerId}`).catch(() => null); const s = sRes ? await readJsonIfOk<{ loggedIn?: boolean; error?: string }>(sRes) : null; if (!aliveRef.current) return; - if (s?.loggedIn) { onAdded(providerId); return; } if (s?.error) { setOauthMsgTone("warn"); setOauthMsg(t("modal.loginError", { error: s.error })); return; } + if (s?.loggedIn) { onAdded(providerId); return; } } setOauthMsgTone("warn"); setOauthMsg(t("modal.loginTimeout")); diff --git a/gui/src/hooks/useCodexAccountPool.ts b/gui/src/hooks/useCodexAccountPool.ts index f23b7f73c..3c1d08fd6 100644 --- a/gui/src/hooks/useCodexAccountPool.ts +++ b/gui/src/hooks/useCodexAccountPool.ts @@ -21,6 +21,8 @@ export interface CodexAccountEntry { plan?: string; /** Required, not optional: the API always distinguishes the app-login row. */ isMain: boolean; + /** Persisted routing exclusion. Paused accounts remain visible but cannot be selected. */ + paused: boolean; hasCredential: boolean; quota: AccountQuota | null; needsReauth?: boolean; @@ -56,10 +58,14 @@ export interface CodexAccountPoolController { activeId: string | null; loadState: CodexAccountLoadState; switchingId: string | null; + pauseUpdatingId: string | null; + pausingExhausted: boolean; activeNeedsReauth: boolean; load(refreshQuota?: boolean): Promise; switchAccount(id: string | null): Promise>; + setAccountPaused(id: string, paused: boolean): Promise; + pauseExhaustedAccounts(): Promise>; saveAlias(id: string, alias: string): Promise; removeAccount(id: string): Promise; syncAfterAccountAdded(): Promise; @@ -78,6 +84,8 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou const [activeId, setActiveId] = useState(null); const [loadState, setLoadState] = useState("loading"); const [switchingId, setSwitchingId] = useState(null); + const [pauseUpdatingId, setPauseUpdatingId] = useState(null); + const [pausingExhausted, setPausingExhausted] = useState(false); // Pause leases live in a ref: pausing must not re-render, and the effect below reads // the live set rather than a captured snapshot. const [pauseCount, setPauseCount] = useState(0); @@ -91,6 +99,7 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou // load already finished read it to seed their UI instead of waiting a poll interval. const lastThresholdRef = useRef<{ value: unknown } | null>(null); const switchingRef = useRef(null); + const pauseMutationRef = useRef<"bulk" | { accountId: string } | null>(null); const subscribeLoadObserver = useCallback((observer: CodexAccountLoadObserver) => { observersRef.current.add(observer); @@ -230,6 +239,73 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou } }, [apiBase, load]); + const setAccountPaused = useCallback(async (id: string, paused: boolean) => { + if (pauseMutationRef.current) return { ok: false, reason: "busy" } as const; + pauseMutationRef.current = { accountId: id }; + setPauseUpdatingId(id); + try { + const response = await fetch(`${apiBase}/api/codex-auth/accounts/pause`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id, paused }), + }); + if (!response.ok) return { ok: false, reason: "request" } as const; + const raw = await response.json().catch(() => ({})); + const result = (raw && typeof raw === "object" ? raw : {}) as { activeCodexAccountId?: string | null }; + setAccounts(current => current.map(account => ( + account.id === id || (id === "__main__" && account.isMain) + ? { ...account, paused } + : account + ))); + if (Object.prototype.hasOwnProperty.call(result, "activeCodexAccountId")) { + const nextActiveId = result.activeCodexAccountId ?? null; + pendingActiveIdRef.current = { id: nextActiveId }; + setActiveId(nextActiveId); + } + void load(); + return { ok: true } as const; + } catch { + return { ok: false, reason: "request" } as const; + } finally { + pauseMutationRef.current = null; + setPauseUpdatingId(null); + } + }, [apiBase, load]); + + const pauseExhaustedAccounts = useCallback(async () => { + if (pauseMutationRef.current) return { ok: false, reason: "busy" } as const; + pauseMutationRef.current = "bulk"; + setPausingExhausted(true); + try { + const response = await fetch(`${apiBase}/api/codex-auth/accounts/pause-exhausted`, { method: "PUT" }); + if (!response.ok) return { ok: false, reason: "request" } as const; + const raw = await response.json().catch(() => ({})); + const result = (raw && typeof raw === "object" ? raw : {}) as { + pausedAccountIds?: string[]; + pausedCount?: number; + activeCodexAccountId?: string | null; + }; + const pausedIds = new Set(result.pausedAccountIds ?? []); + setAccounts(current => current.map(account => ( + pausedIds.has(account.id) || (pausedIds.has("__main__") && account.isMain) + ? { ...account, paused: true } + : account + ))); + if (Object.prototype.hasOwnProperty.call(result, "activeCodexAccountId")) { + const nextActiveId = result.activeCodexAccountId ?? null; + pendingActiveIdRef.current = { id: nextActiveId }; + setActiveId(nextActiveId); + } + void load(); + return { ok: true, pausedCount: result.pausedCount ?? pausedIds.size } as const; + } catch { + return { ok: false, reason: "request" } as const; + } finally { + pauseMutationRef.current = null; + setPausingExhausted(false); + } + }, [apiBase, load]); + const removeAccount = useCallback(async (id: string) => { try { const response = await fetch( @@ -254,16 +330,21 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou : null; const mainAccount = accounts.find(a => a.isMain); // Include health-only reauth so Providers overview attention matches row CTAs. - const activeNeedsReauth = accountNeedsReauth(activePoolAccount ?? mainAccount); + const activeAccount = activePoolAccount ?? mainAccount; + const activeNeedsReauth = !activeAccount?.paused && accountNeedsReauth(activeAccount); return { accounts, activeId, loadState, switchingId, + pauseUpdatingId, + pausingExhausted, activeNeedsReauth, load, switchAccount, + setAccountPaused, + pauseExhaustedAccounts, saveAlias, removeAccount, syncAfterAccountAdded, diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 11167fc3b..5e85333ed 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -646,7 +646,20 @@ export const de: Record = { "codexAuth.refreshingQuota": "Aktualisiere…", "codexAuth.quotaRefreshed": "Kontingente aktualisiert", "codexAuth.quotaRefreshFailed": "Kontingente konnten nicht aktualisiert werden", + "codexAuth.pauseExhausted": "Ausgeschöpfte pausieren", + "codexAuth.pausingExhausted": "Kontingente werden geprüft…", + "codexAuth.pauseExhaustedSucceeded": "Konten am Limit pausiert: {count}", + "codexAuth.pauseExhaustedNone": "Keine Konten mit bestätigter 100-%-Nutzung.", + "codexAuth.pauseExhaustedFailed": "Ausgeschöpfte Konten konnten nicht geprüft und pausiert werden.", "codexAuth.noPool": "Noch keine Pool-Konten hinzugefügt.", + "codexAuth.pause": "Pausieren", + "codexAuth.resume": "Fortsetzen", + "codexAuth.paused": "PAUSIERT", + "codexAuth.pauseSucceeded": "{email} ist pausiert", + "codexAuth.resumeSucceeded": "{email} ist wieder im Pool verfügbar", + "codexAuth.pauseFailed": "{email} konnte nicht pausiert werden. Es wurde nichts geändert.", + "codexAuth.resumeFailed": "{email} konnte nicht fortgesetzt werden. Es wurde nichts geändert.", + "codexAuth.pausedHint": "Bis zur Fortsetzung von automatischem Wechsel, Wiederholungen, Cooldown-Wiederherstellung und manueller Auswahl ausgeschlossen.", "codexAuth.fiveHour": "5 Std.", "codexAuth.weekly": "Woche", "codexAuth.monthly": "30d", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index edb244f48..26aa5f653 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1060,7 +1060,20 @@ export const en = { "codexAuth.refreshingQuota": "Refreshing...", "codexAuth.quotaRefreshed": "Quotas refreshed", "codexAuth.quotaRefreshFailed": "Failed to refresh quotas", + "codexAuth.pauseExhausted": "Pause exhausted", + "codexAuth.pausingExhausted": "Checking quotas...", + "codexAuth.pauseExhaustedSucceeded": "Accounts at the limit paused: {count}", + "codexAuth.pauseExhaustedNone": "No accounts have confirmed 100% usage.", + "codexAuth.pauseExhaustedFailed": "Failed to check and pause exhausted accounts.", "codexAuth.noPool": "No pool accounts added yet.", + "codexAuth.pause": "Pause", + "codexAuth.resume": "Resume", + "codexAuth.paused": "PAUSED", + "codexAuth.pauseSucceeded": "{email} is paused", + "codexAuth.resumeSucceeded": "{email} is available to the pool again", + "codexAuth.pauseFailed": "Could not pause {email}. Nothing was changed.", + "codexAuth.resumeFailed": "Could not resume {email}. Nothing was changed.", + "codexAuth.pausedHint": "Excluded from automatic switching, retries, cooldown recovery, and manual selection until resumed.", "codexAuth.fiveHour": "5h", "codexAuth.weekly": "Week", "codexAuth.monthly": "30d", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 3a6981701..47b1de3f0 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1017,7 +1017,20 @@ export const ja: Record = { "codexAuth.refreshingQuota": "更新中...", "codexAuth.quotaRefreshed": "クォータを更新しました", "codexAuth.quotaRefreshFailed": "クォータの更新に失敗しました", + "codexAuth.pauseExhausted": "上限到達を一括停止", + "codexAuth.pausingExhausted": "クォータを確認中...", + "codexAuth.pauseExhaustedSucceeded": "上限に達したアカウントを停止しました: {count}", + "codexAuth.pauseExhaustedNone": "使用率 100% が確認されたアカウントはありません。", + "codexAuth.pauseExhaustedFailed": "上限到達アカウントの確認と停止に失敗しました。", "codexAuth.noPool": "まだプールアカウントは追加されていません。", + "codexAuth.pause": "一時停止", + "codexAuth.resume": "再開", + "codexAuth.paused": "一時停止中", + "codexAuth.pauseSucceeded": "{email} を一時停止しました", + "codexAuth.resumeSucceeded": "{email} をアカウントプールに戻しました", + "codexAuth.pauseFailed": "{email} を一時停止できませんでした。変更はありません。", + "codexAuth.resumeFailed": "{email} を再開できませんでした。変更はありません。", + "codexAuth.pausedHint": "再開するまで、自動切り替え、再試行、クールダウン復旧、手動選択の対象外です。", "codexAuth.fiveHour": "5時間", "codexAuth.weekly": "週", "codexAuth.monthly": "30日", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 5e206ab31..2372bf500 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -663,7 +663,20 @@ export const ko: Record = { "codexAuth.refreshingQuota": "새로고침 중...", "codexAuth.quotaRefreshed": "할당량을 다시 조회했습니다", "codexAuth.quotaRefreshFailed": "할당량 재조회에 실패했습니다", + "codexAuth.pauseExhausted": "한도 도달 계정 일시 중지", + "codexAuth.pausingExhausted": "할당량 확인 중...", + "codexAuth.pauseExhaustedSucceeded": "한도에 도달해 일시 중지된 계정: {count}", + "codexAuth.pauseExhaustedNone": "사용량 100%가 확인된 계정이 없습니다.", + "codexAuth.pauseExhaustedFailed": "한도 도달 계정을 확인하고 일시 중지하지 못했습니다.", "codexAuth.noPool": "풀 계정이 아직 없습니다.", + "codexAuth.pause": "일시 중지", + "codexAuth.resume": "재개", + "codexAuth.paused": "일시 중지됨", + "codexAuth.pauseSucceeded": "{email} 계정을 일시 중지했습니다", + "codexAuth.resumeSucceeded": "{email} 계정을 풀에서 다시 사용할 수 있습니다", + "codexAuth.pauseFailed": "{email} 계정을 일시 중지하지 못했습니다. 변경 사항이 없습니다.", + "codexAuth.resumeFailed": "{email} 계정을 재개하지 못했습니다. 변경 사항이 없습니다.", + "codexAuth.pausedHint": "재개할 때까지 자동 전환, 재시도, 쿨다운 복구 및 수동 선택에서 제외됩니다.", "codexAuth.fiveHour": "5시간", "codexAuth.weekly": "주간", "codexAuth.monthly": "30일", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 97e1d68da..e0ae4bdf4 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1059,7 +1059,20 @@ export const ru: Record = { "codexAuth.refreshingQuota": "Обновление...", "codexAuth.quotaRefreshed": "Квоты обновлены", "codexAuth.quotaRefreshFailed": "Не удалось обновить квоты", + "codexAuth.pauseExhausted": "Приостановить исчерпанные", + "codexAuth.pausingExhausted": "Проверка квот...", + "codexAuth.pauseExhaustedSucceeded": "Приостановлено аккаунтов на лимите: {count}", + "codexAuth.pauseExhaustedNone": "Нет аккаунтов с подтверждённым использованием 100%.", + "codexAuth.pauseExhaustedFailed": "Не удалось проверить и приостановить исчерпанные аккаунты.", "codexAuth.noPool": "В пул ещё не добавлено ни одного аккаунта.", + "codexAuth.pause": "Приостановить", + "codexAuth.resume": "Возобновить", + "codexAuth.paused": "ПРИОСТАНОВЛЕН", + "codexAuth.pauseSucceeded": "Аккаунт {email} приостановлен", + "codexAuth.resumeSucceeded": "Аккаунт {email} снова доступен в пуле", + "codexAuth.pauseFailed": "Не удалось приостановить {email}. Изменений нет.", + "codexAuth.resumeFailed": "Не удалось возобновить {email}. Изменений нет.", + "codexAuth.pausedHint": "До возобновления исключён из автоматического переключения, повторов, восстановления после задержки и ручного выбора.", "codexAuth.fiveHour": "5 ч", "codexAuth.weekly": "Неделя", "codexAuth.monthly": "30 дн.", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index ee16b561c..3a50a7658 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -663,7 +663,20 @@ export const zh: Record = { "codexAuth.refreshingQuota": "刷新中...", "codexAuth.quotaRefreshed": "额度已刷新", "codexAuth.quotaRefreshFailed": "额度刷新失败", + "codexAuth.pauseExhausted": "暂停已达上限账号", + "codexAuth.pausingExhausted": "正在检查额度...", + "codexAuth.pauseExhaustedSucceeded": "已暂停 {count} 个达到上限的账号", + "codexAuth.pauseExhaustedNone": "没有确认达到 100% 用量的账号。", + "codexAuth.pauseExhaustedFailed": "无法检查并暂停已达上限账号。", "codexAuth.noPool": "尚未添加池账号。", + "codexAuth.pause": "暂停", + "codexAuth.resume": "恢复", + "codexAuth.paused": "已暂停", + "codexAuth.pauseSucceeded": "已暂停 {email}", + "codexAuth.resumeSucceeded": "{email} 已重新加入账号池", + "codexAuth.pauseFailed": "无法暂停 {email},未做任何更改。", + "codexAuth.resumeFailed": "无法恢复 {email},未做任何更改。", + "codexAuth.pausedHint": "恢复前不会参与自动切换、重试、冷却恢复或手动选择。", "codexAuth.fiveHour": "5 小时", "codexAuth.weekly": "每周", "codexAuth.monthly": "30天", diff --git a/gui/src/icons.tsx b/gui/src/icons.tsx index 563dbc6a9..0e807d197 100644 --- a/gui/src/icons.tsx +++ b/gui/src/icons.tsx @@ -21,6 +21,8 @@ export const IconCheck = (p: P) => ( (); export const IconPlus = (p: P) => (); export const IconRefresh = (p: P) => (); +export const IconPause = (p: P) => (); +export const IconPlay = (p: P) => (); export const IconTrash = (p: P) => (); export const IconAlert = (p: P) => (); export const IconInfo = (p: P) => (); diff --git a/gui/src/intl-formatters.ts b/gui/src/intl-formatters.ts index 4693170c7..e096931b7 100644 --- a/gui/src/intl-formatters.ts +++ b/gui/src/intl-formatters.ts @@ -19,16 +19,42 @@ export function cachedNumberFormat( return fmt; } -const CREDIT_DATE_FORMAT = new Intl.DateTimeFormat(undefined, { +const CREDIT_DATE_OPTIONS: Intl.DateTimeFormatOptions = { month: "short", day: "numeric", year: "numeric", -}); +}; -export function formatCreditDate(iso: string): string { +const CREDIT_DATE_TIME_OPTIONS: Intl.DateTimeFormatOptions = { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", +}; + +const dateFormatters = new Map(); + +function cachedDateFormatter(locale: string | undefined, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat { + const key = cacheKey(locale, options); + let fmt = dateFormatters.get(key); + if (!fmt) { + fmt = new Intl.DateTimeFormat(locale, options); + dateFormatters.set(key, fmt); + } + return fmt; +} + +export function formatCreditDate(iso: string, locale?: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return "\u2014"; + return cachedDateFormatter(locale, CREDIT_DATE_OPTIONS).format(date); +} + +export function formatCreditDateTime(iso: string, locale?: string): string { const date = new Date(iso); if (Number.isNaN(date.getTime())) return "\u2014"; - return CREDIT_DATE_FORMAT.format(date); + return cachedDateFormatter(locale, CREDIT_DATE_TIME_OPTIONS).format(date); } /** Format a USD cost estimate for display. Returns "—" when unavailable. */ diff --git a/gui/tests/active-account-reauth-aggregate.test.ts b/gui/tests/active-account-reauth-aggregate.test.ts index e606c2347..fee1ba6f0 100644 --- a/gui/tests/active-account-reauth-aggregate.test.ts +++ b/gui/tests/active-account-reauth-aggregate.test.ts @@ -21,7 +21,8 @@ describe("Codex callback aggregate path", () => { test("shared Codex controller derives activeNeedsReauth from health as well", async () => { const hook = await Bun.file(new URL("../src/hooks/useCodexAccountPool.ts", import.meta.url)).text(); - expect(hook).toContain("accountNeedsReauth(activePoolAccount ?? mainAccount)"); + expect(hook).toContain("const activeAccount = activePoolAccount ?? mainAccount"); + expect(hook).toContain("!activeAccount?.paused && accountNeedsReauth(activeAccount)"); expect(hook).not.toMatch(/activePoolAccount\s*\?\s*Boolean\(activePoolAccount\.needsReauth\)/); }); diff --git a/gui/tests/add-provider-oauth-url-leak.test.tsx b/gui/tests/add-provider-oauth-url-leak.test.tsx index 9e0a04ecc..8265f6407 100644 --- a/gui/tests/add-provider-oauth-url-leak.test.tsx +++ b/gui/tests/add-provider-oauth-url-leak.test.tsx @@ -1,9 +1,10 @@ -import { afterEach, beforeEach, expect, test } from "bun:test"; +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; import { Window } from "happy-dom"; import { act } from "react"; import type { Root } from "react-dom/client"; import { LanguageProvider } from "../src/i18n/provider"; import AddProviderModal from "../src/components/AddProviderModal"; +import { OAUTH_LOGIN_POLL_INTERVAL_MS } from "../src/components/use-add-provider-oauth"; /** * The add-provider OAuth pane renders the authorization URL so a user whose @@ -23,6 +24,7 @@ let host: HTMLElement; let root: Root | null = null; let originalFetch: typeof globalThis.fetch; let pendingLogins: Array<(url: string) => void> = []; +let oauthStatus: { loggedIn: boolean; error?: string } = { loggedIn: false }; const PRESETS = [ { id: "claude", label: "Claude", adapter: "anthropic", baseUrl: "https://api.anthropic.com", auth: "oauth", oauthProvider: "claude" }, @@ -43,6 +45,7 @@ beforeEach(() => { (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; pendingLogins = []; + oauthStatus = { loggedIn: false }; Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL, init?: RequestInit) => { @@ -56,7 +59,7 @@ beforeEach(() => { pendingLogins.push((authUrl: string) => resolve(Response.json({ url: authUrl }))); }); } - if (url.pathname === "/api/oauth/status") return Response.json({ loggedIn: false }); + if (url.pathname === "/api/oauth/status") return Response.json(oauthStatus); return Response.json({}); }, }); @@ -78,13 +81,13 @@ afterEach(async () => { await win.happyDOM?.close?.(); }); -async function mountModal() { +async function mountModal(onAdded: (name: string) => void = () => {}) { const { createRoot } = await import("react-dom/client"); await act(async () => { root = createRoot(host); root.render( - {}} onAdded={() => {}} /> + {}} onAdded={onAdded} /> , ); }); @@ -167,3 +170,46 @@ test("a late URL for an abandoned provider cannot overwrite the one already show expect(host.querySelector(".login-url-block-text")?.textContent).toBe(B_URL); expect(host.textContent).not.toContain(A_URL); }); + +test("a login error wins over a retained OAuth credential", async () => { + const added: string[] = []; + oauthStatus = { + loggedIn: true, + error: "The credential was saved, but the provider entry was not written.", + }; + const realSetTimeout = globalThis.setTimeout; + const timeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation((( + callback: (...args: unknown[]) => void, + delay?: number, + ...args: unknown[] + ) => { + if (delay === OAUTH_LOGIN_POLL_INTERVAL_MS) { + queueMicrotask(() => callback(...args)); + return 0 as unknown as ReturnType; + } + return realSetTimeout(callback, delay, ...args); + }) as typeof setTimeout); + + try { + await mountModal(name => added.push(name)); + await act(async () => { + clickByText("Claude"); + await new Promise((r) => setTimeout(r, 20)); + }); + await act(async () => { + clickByText("Log in with Claude"); + await new Promise((r) => setTimeout(r, 20)); + }); + + expect(pendingLogins).toHaveLength(1); + await act(async () => { + pendingLogins.shift()!(A_URL); + await new Promise((r) => setTimeout(r, 40)); + }); + + expect(added).toEqual([]); + expect(host.textContent).toContain("provider entry was not written"); + } finally { + timeoutSpy.mockRestore(); + } +}); diff --git a/gui/tests/api-auth-memory.test.ts b/gui/tests/api-auth-memory.test.ts index 4d27d8600..3bc008d70 100644 --- a/gui/tests/api-auth-memory.test.ts +++ b/gui/tests/api-auth-memory.test.ts @@ -113,6 +113,172 @@ test("cross-origin /api/* requests do not receive the API key or token prompt", expect(promptCalls).toBe(beforeCrossPrompts); }); +test("concurrent 401s share one token prompt and all retry with the stored token", async () => { + // Repro for #647: many /api/* requests start without a token (dashboard fan-out). + // Delivering 401s one-by-one after each auth cycle finishes matches the browser case where + // window.prompt blocks the main thread: each continuation still holds a captured null token + // and must reuse the in-memory token from an earlier request instead of prompting again. + let promptCalls = 0; + const release401: Array<() => void> = []; + const mockFetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers); + if (headers.get("X-OpenCodex-API-Key") === "shared-token") { + return new Response("{}", { status: 200 }); + } + await new Promise((resolve) => { + release401.push(resolve); + }); + return new Response("unauthorized", { status: 401 }); + }) as typeof fetch; + window.prompt = () => { + promptCalls += 1; + return "shared-token"; + }; + await installMockAuthFetch(mockFetch); + + const endpoints = [ + "/api/config", + "/api/providers", + "/api/models", + "/api/selected-models", + "/api/disabled-models", + "/api/effort-caps", + "/api/sidecar-settings", + "/api/injection-model", + "/api/v2", + "/api/keys", + "/api/provider-presets", + "/api/key-providers", + "/api/oauth/providers", + "/api/codex-auth/accounts", + ]; + const pending = endpoints.map((path) => fetch(path).then((r) => r.status)); + // Let every request reach the 401 gate before any response is delivered. + for (let i = 0; i < 20 && release401.length < endpoints.length; i += 1) { + await Promise.resolve(); + } + expect(release401.length).toBe(endpoints.length); + + for (let i = 0; i < endpoints.length; i += 1) { + const done = pending[i]!; + let settled = false; + void done.then(() => { + settled = true; + }); + release401.shift()!(); + for (let spin = 0; spin < 50 && !settled; spin += 1) { + await Promise.resolve(); + } + expect(settled).toBe(true); + } + + const statuses = await Promise.all(pending); + expect(promptCalls).toBe(1); + expect([...new Set(statuses)]).toEqual([200]); +}); + +test("stale concurrent 401 does not clear a token refreshed by another request", async () => { + // Codex/CodeRabbit race: request A prompts and stores T2; request B still holding stale T1 + // must not wipe T2 (clearTokenIfCurrent) before its re-read / shared gate join. + let promptCalls = 0; + let acceptV1 = true; + const release401: Array<() => void> = []; + const mockFetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers); + const key = headers.get("X-OpenCodex-API-Key"); + if (key === "token-v2") return new Response("{}", { status: 200 }); + if (acceptV1 && key === "token-v1") return new Response("{}", { status: 200 }); + if (key === "token-v1") { + await new Promise((resolve) => { + release401.push(resolve); + }); + return new Response("unauthorized", { status: 401 }); + } + return new Response("unauthorized", { status: 401 }); + }) as typeof fetch; + window.prompt = () => { + promptCalls += 1; + return "token-v1"; + }; + await installMockAuthFetch(mockFetch); + expect((await fetch("/api/config")).status).toBe(200); + expect(promptCalls).toBe(1); + + acceptV1 = false; + promptCalls = 0; + window.prompt = () => { + promptCalls += 1; + return "token-v2"; + }; + + const pending = [fetch("/api/config"), fetch("/api/providers")].map((p) => p.then((r) => r.status)); + for (let i = 0; i < 20 && release401.length < 2; i += 1) { + await Promise.resolve(); + } + expect(release401.length).toBe(2); + + for (let i = 0; i < 2; i += 1) { + const done = pending[i]!; + let settled = false; + void done.then(() => { + settled = true; + }); + release401.shift()!(); + for (let spin = 0; spin < 50 && !settled; spin += 1) { + await Promise.resolve(); + } + expect(settled).toBe(true); + } + + const statuses = await Promise.all(pending); + expect(promptCalls).toBe(1); + expect([...new Set(statuses)]).toEqual([200]); +}); + +test("canceling the token prompt once does not reopen it for the rest of the 401 fan-out", async () => { + let promptCalls = 0; + const release401: Array<() => void> = []; + const mockFetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers); + if (headers.get("X-OpenCodex-API-Key")) { + return new Response("{}", { status: 200 }); + } + await new Promise((resolve) => { + release401.push(resolve); + }); + return new Response("unauthorized", { status: 401 }); + }) as typeof fetch; + window.prompt = () => { + promptCalls += 1; + return null; + }; + await installMockAuthFetch(mockFetch); + + const endpoints = ["/api/config", "/api/providers", "/api/models", "/api/keys"]; + const pending = endpoints.map((path) => fetch(path).then((r) => r.status)); + for (let i = 0; i < 20 && release401.length < endpoints.length; i += 1) { + await Promise.resolve(); + } + expect(release401.length).toBe(endpoints.length); + + for (let i = 0; i < endpoints.length; i += 1) { + const done = pending[i]!; + let settled = false; + void done.then(() => { + settled = true; + }); + release401.shift()!(); + for (let spin = 0; spin < 50 && !settled; spin += 1) { + await Promise.resolve(); + } + expect(settled).toBe(true); + } + + const statuses = await Promise.all(pending); + expect(promptCalls).toBe(1); + expect([...new Set(statuses)]).toEqual([401]); +}); + test("cross-origin /v1/* requests do not receive the API key or token prompt", async () => { let promptCalls = 0; let phase: "seed" | "cross" = "seed"; diff --git a/gui/tests/codex-account-pool-behaviour.test.tsx b/gui/tests/codex-account-pool-behaviour.test.tsx index 84a58bb0c..2b02a4516 100644 --- a/gui/tests/codex-account-pool-behaviour.test.tsx +++ b/gui/tests/codex-account-pool-behaviour.test.tsx @@ -22,6 +22,10 @@ let calls: string[] = []; let originalFetch: typeof globalThis.fetch; let accounts: unknown[] = []; let threshold = 80; +let nextAccountsResponseGate: Promise | null = null; +let pauseResponseActiveId: string | null = null; +let bulkPausedAccountIds: string[] = ["a2"]; +let bulkResponseActiveId: string | null = null; beforeEach(() => { previous = Object.fromEntries(globals.map((k) => [k, Reflect.get(globalThis, k)])) as typeof previous; @@ -36,13 +40,47 @@ beforeEach(() => { originalFetch = globalThis.fetch; calls = []; - accounts = [{ id: "a1", email: "account-one", isMain: true, hasCredential: true, quota: null }]; + nextAccountsResponseGate = null; + pauseResponseActiveId = null; + bulkPausedAccountIds = ["a2"]; + bulkResponseActiveId = null; + accounts = [{ id: "a1", email: "account-one", isMain: true, paused: false, hasCredential: true, quota: null }]; Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (url: string, init?: RequestInit) => { const path = String(url).split("/api/")[1] ?? String(url); calls.push(`${init?.method ?? "GET"} ${path}`); + if (path === "codex-auth/accounts/pause") { + const body = JSON.parse(String(init?.body)) as { id: string; paused: boolean }; + accounts = accounts.map(account => ( + typeof account === "object" && account !== null && "id" in account + && (account.id === body.id || (body.id === "__main__" && "isMain" in account && account.isMain === true)) + ? { ...account, paused: body.paused } + : account + )); + return { ok: true, json: async () => ({ activeCodexAccountId: pauseResponseActiveId }) } as unknown as Response; + } + if (path === "codex-auth/accounts/pause-exhausted") { + const pausedIds = new Set(bulkPausedAccountIds); + accounts = accounts.map(account => ( + typeof account === "object" && account !== null && "id" in account + && (pausedIds.has(String(account.id)) || (pausedIds.has("__main__") && "isMain" in account && account.isMain === true)) + ? { ...account, paused: true } + : account + )); + return { + ok: true, + json: async () => ({ + pausedAccountIds: bulkPausedAccountIds, + pausedCount: bulkPausedAccountIds.length, + activeCodexAccountId: bulkResponseActiveId, + }), + } as unknown as Response; + } if (path.startsWith("codex-auth/accounts")) { + const gate = nextAccountsResponseGate; + nextAccountsResponseGate = null; + if (gate) await gate; return { ok: true, json: async () => ({ accounts }) } as unknown as Response; } if (path.startsWith("codex-auth/active")) { @@ -105,6 +143,109 @@ test("an inert controller issues no requests at all", async () => { expect(calls.length).toBe(0); }); +test("pausing an account writes the persisted endpoint and updates shared state", async () => { + const seen = await mountController(); + + await act(async () => { + expect(await seen.current!.setAccountPaused("a1", true)).toEqual({ ok: true }); + }); + await act(async () => { await new Promise((r) => setTimeout(r, 30)); }); + + expect(calls).toContain("PUT codex-auth/accounts/pause"); + expect(seen.current!.accounts[0]?.paused).toBe(true); + expect(seen.current!.activeId).toBeNull(); +}); + +test("pausing the main sentinel updates its distinct account row before reload", async () => { + const seen = await mountController(); + let releaseReload!: () => void; + nextAccountsResponseGate = new Promise(resolve => { releaseReload = resolve; }); + + await act(async () => { + expect(await seen.current!.setAccountPaused("__main__", true)).toEqual({ ok: true }); + }); + + expect(calls).toContain("PUT codex-auth/accounts/pause"); + expect(seen.current!.accounts.find(account => account.isMain)?.id).toBe("a1"); + expect(seen.current!.accounts.find(account => account.isMain)?.paused).toBe(true); + + await act(async () => { + releaseReload(); + await new Promise((resolve) => setTimeout(resolve, 30)); + }); +}); + +test("pausing stores the actual fallback account returned by the API", async () => { + accounts = [ + { id: "a1", email: "main", isMain: true, paused: false, hasCredential: true, quota: null }, + { id: "a2", email: "next", isMain: false, paused: false, hasCredential: true, quota: null }, + ]; + pauseResponseActiveId = "a2"; + const seen = await mountController(); + + await act(async () => { + expect(await seen.current!.setAccountPaused("__main__", true)).toEqual({ ok: true }); + }); + + expect(seen.current!.activeId).toBe("a2"); +}); + +test("a paused main account does not contribute active reauth state", async () => { + accounts = [ + { id: "a1", email: "main", isMain: true, paused: true, hasCredential: true, needsReauth: true, quota: null }, + { id: "a2", email: "next", isMain: false, paused: false, hasCredential: true, quota: null }, + ]; + const seen = await mountController(); + + expect(seen.current!.activeId).toBeNull(); + expect(seen.current!.activeNeedsReauth).toBe(false); +}); + +test("bulk pausing writes one endpoint and updates every returned account", async () => { + accounts = [ + { id: "a1", email: "account-one", isMain: true, paused: false, hasCredential: true, quota: null }, + { id: "a2", email: "account-two", isMain: false, paused: false, hasCredential: true, quota: null }, + ]; + const seen = await mountController(); + + await act(async () => { + expect(await seen.current!.pauseExhaustedAccounts()).toEqual({ ok: true, pausedCount: 1 }); + }); + await act(async () => { await new Promise((r) => setTimeout(r, 30)); }); + + expect(calls).toContain("PUT codex-auth/accounts/pause-exhausted"); + expect(seen.current!.accounts.find(account => account.id === "a2")?.paused).toBe(true); + expect(seen.current!.pausingExhausted).toBe(false); +}); + +test("bulk pausing translates the main sentinel to its distinct account row", async () => { + accounts = [ + { id: "a1", email: "main", isMain: true, paused: false, hasCredential: true, quota: null }, + { id: "a2", email: "pool", isMain: false, paused: false, hasCredential: true, quota: null }, + ]; + bulkPausedAccountIds = ["__main__"]; + bulkResponseActiveId = "a2"; + const seen = await mountController(); + let releaseReload!: () => void; + nextAccountsResponseGate = new Promise(resolve => { releaseReload = resolve; }); + + await act(async () => { + expect(await seen.current!.pauseExhaustedAccounts()).toEqual({ ok: true, pausedCount: 1 }); + }); + + expect(seen.current!.accounts.find(account => account.isMain)?.id).toBe("a1"); + expect(seen.current!.accounts.find(account => account.isMain)?.paused).toBe(true); + expect(seen.current!.activeId).toBe("a2"); + + await act(async () => { + releaseReload(); + await new Promise((resolve) => setTimeout(resolve, 30)); + }); + + expect(seen.current!.accounts.find(account => account.isMain)?.paused).toBe(true); + expect(seen.current!.activeId).toBe("a2"); +}); + test("two pause holders both have to release before polling resumes", async () => { const seen = await mountController(); const controller = seen.current!; diff --git a/gui/tests/codex-account-pool-controller.test.ts b/gui/tests/codex-account-pool-controller.test.ts index c27637aab..ae2f65722 100644 --- a/gui/tests/codex-account-pool-controller.test.ts +++ b/gui/tests/codex-account-pool-controller.test.ts @@ -14,8 +14,8 @@ test("the controller is the single data owner and exposes the agreed contract", // Data layer (Q6): list / active / loading / switching plus the mutating actions. for (const member of [ - "accounts", "activeId", "loadState", "switchingId", "activeNeedsReauth", - "load", "switchAccount", "saveAlias", "removeAccount", "syncAfterAccountAdded", + "accounts", "activeId", "loadState", "switchingId", "pauseUpdatingId", "pausingExhausted", "activeNeedsReauth", + "load", "switchAccount", "setAccountPaused", "pauseExhaustedAccounts", "saveAlias", "removeAccount", "syncAfterAccountAdded", ]) { expect(hook).toContain(member); } @@ -31,6 +31,28 @@ test("the controller is the single data owner and exposes the agreed contract", expect(hook).not.toContain("load(refreshQuota?: boolean, observer"); }); +test("main and added account cards expose the same persisted pause control", async () => { + const pool = await read("../src/components/CodexAccountPool.tsx"); + const mainCard = await read("../src/components/codex-account-pool-main-card.tsx"); + const addedCards = await read("../src/components/codex-account-pool-cards.tsx"); + + expect(pool).toContain("controller.setAccountPaused(account.id, paused)"); + expect(mainCard).toContain("onTogglePause(mainSwitchEntry)"); + expect(addedCards).toContain("onTogglePause(a)"); + expect(mainCard).toContain('t(main.paused ? "codexAuth.resume" : "codexAuth.pause")'); + expect(addedCards).toContain('t(a.paused ? "codexAuth.resume" : "codexAuth.pause")'); +}); + +test("the pool header exposes one bulk action backed by the atomic endpoint", async () => { + const pool = await read("../src/components/CodexAccountPool.tsx"); + const mainCard = await read("../src/components/codex-account-pool-main-card.tsx"); + const hook = await read("../src/hooks/useCodexAccountPool.ts"); + + expect(pool).toContain("controller.pauseExhaustedAccounts()"); + expect(mainCard).toContain('t("codexAuth.pauseExhausted")'); + expect(hook).toContain("/api/codex-auth/accounts/pause-exhausted"); +}); + test("pause is a token lease, so two holders cannot cancel each other", async () => { const hook = await read("../src/hooks/useCodexAccountPool.ts"); diff --git a/gui/tests/codex-account-pool-toast-tone.test.tsx b/gui/tests/codex-account-pool-toast-tone.test.tsx index af2db2f8f..45ec9fc08 100644 --- a/gui/tests/codex-account-pool-toast-tone.test.tsx +++ b/gui/tests/codex-account-pool-toast-tone.test.tsx @@ -22,6 +22,7 @@ const account: CodexAccountEntry = { id: "pool-1", email: "pool@example.test", isMain: false, + paused: false, hasCredential: true, quota: { resetCredits: 2, updatedAt: 1 }, }; @@ -29,15 +30,19 @@ const account: CodexAccountEntry = { function makeController(overrides: Partial = {}): CodexAccountPoolController { return { accounts: [ - { id: "main", email: "main@example.test", isMain: true, hasCredential: true, quota: null }, + { id: "main", email: "main@example.test", isMain: true, paused: false, hasCredential: true, quota: null }, account, ], activeId: null, loadState: "ready", switchingId: null, + pauseUpdatingId: null, + pausingExhausted: false, activeNeedsReauth: false, load: async () => true, switchAccount: async () => ({ ok: true, activeId: null }), + setAccountPaused: async () => ({ ok: true }), + pauseExhaustedAccounts: async () => ({ ok: true, pausedCount: 0 }), saveAlias: async () => ({ ok: true }), removeAccount: async () => ({ ok: false, reason: "request" }), syncAfterAccountAdded: async () => ({ ok: true }), diff --git a/gui/tests/intl-formatters.test.ts b/gui/tests/intl-formatters.test.ts new file mode 100644 index 000000000..fa38faace --- /dev/null +++ b/gui/tests/intl-formatters.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test"; +import { formatCreditDate, formatCreditDateTime } from "../src/intl-formatters"; + +describe("credit date formatting", () => { + test("keeps the compact date format for grant dates", () => { + const iso = "2026-07-31T12:34:56Z"; + const time = new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit" }).format(new Date(iso)); + + expect(formatCreditDate(iso, "de-DE")).not.toContain(time); + }); + + test("includes the local time for expiration dates", () => { + const iso = "2026-07-31T12:34:56Z"; + + const time = new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit" }).format(new Date(iso)); + + expect(formatCreditDateTime(iso, "de-DE")).toContain(time); + expect(formatCreditDateTime(iso, "de-DE")).not.toBe("—"); + }); + + test("handles invalid dates consistently", () => { + expect(formatCreditDateTime("invalid")).toBe("—"); + }); +}); diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md new file mode 100644 index 000000000..da4c2d0a1 --- /dev/null +++ b/scripts/AGENTS.md @@ -0,0 +1,27 @@ +# Script and release-tooling instructions + +This file applies to `scripts/` and inherits the repository-wide rules in `/AGENTS.md`. + +## Safety boundary + +- `scripts/release.ts` is the release authority. +- Release, publish, version-bump, deployment, and package-signing behavior is maintainer-controlled. +- Never run `bun run release`, a publish command, or a deployment command unless the task explicitly requires the action and `MAINTAINERS.md` permits it. +- Changes to release, packaging, dependency-installation, credential, or repository-automation scripts require explicit security review. + +## Implementation rules + +- Preserve Linux, macOS, and Windows behavior. Do not add shell-specific assumptions to cross-platform scripts. +- Use explicit paths, deterministic inputs, bounded resource use, and actionable failures. +- Use atomic replacement for files whose partial write would corrupt configuration, package metadata, release state, or recovery data. +- Do not log secrets, tokens, request bodies, account identifiers, private paths, or personal data. +- Do not weaken dry-run, exact-commit, CI-success, or explicit-confirmation gates. +- Generated package assets must be produced by the owning preparation command, not edited manually. + +## Required validation + +- Run focused tests or probes for the changed script. +- Run `bun run typecheck`. +- Run `bun run privacy:scan` when the script handles configuration, credentials, requests, logs, or account data. +- Run `bun run prepush` for release, packaging, dependency, or cross-platform tooling changes. +- Report any platform-specific validation that was not executed. diff --git a/src/AGENTS.md b/src/AGENTS.md new file mode 100644 index 000000000..ab7a5fef4 --- /dev/null +++ b/src/AGENTS.md @@ -0,0 +1,28 @@ +# Source runtime instructions + +This file applies to `src/` and inherits the repository-wide rules in `/AGENTS.md`. + +## Runtime and module rules + +- `src/` is Bun-native TypeScript in strict mode and uses ES modules only. +- Do not assume a separate server compilation step. +- Prefer Bun and Web-platform APIs. Introduce a Node-only runtime dependency only when the task explicitly requires compatibility code and the owning module already has that role. +- Preserve existing public exports and configuration compatibility unless the task explicitly changes them. +- Read the applicable documents in `structure/` before changing shared routing, adapters, transports, sidecars, authentication, configuration, or server architecture. + +## Implementation rules + +- Follow the existing subsystem boundaries and naming patterns. +- Do not combine unrelated responsibilities to avoid creating another large shared module. +- Handle asynchronous failures at request, transport, and sidecar boundaries. Optional integrations must degrade through the existing failure representation rather than crash the request path. +- Provider catalog metadata belongs in the canonical provider registry and derivation flow. Do not duplicate provider facts across independent pickers or seeds. +- Adapter changes must preserve the internal event contract, streaming behavior, tool calls, cancellation, error mapping, and image handling relevant to that adapter. +- Authentication, OAuth, token, credential, management API, and CORS changes are security-boundary changes. + +## Tests and validation + +- Place focused regression coverage near the existing tests for the affected subsystem. +- For focused behavior, run the relevant `bun test tests/.test.ts` and `bun run typecheck`. +- For shared routing, adapters, config, OAuth, or server behavior, also run `bun run test`. +- For logging, requests, credentials, account data, or fixtures, also run `bun run privacy:scan`. +- Update `docs-site/` when the change affects user-visible behavior or configuration. diff --git a/src/adapters/cursor/native-exec-shell.ts b/src/adapters/cursor/native-exec-shell.ts index 27c07fd71..cda058fca 100644 --- a/src/adapters/cursor/native-exec-shell.ts +++ b/src/adapters/cursor/native-exec-shell.ts @@ -23,8 +23,20 @@ import { errorText, execBytes, execStreamCloseBytes } from "./native-exec-common const backgroundShells = new Map(); let nextShellId = 1; -const NATIVE_SHELL_DISABLED = - "Route this through the Codex bridge shell tool from the current catalog (`shell_command` or `exec_command`, including the long `mcp_opencodex-responses_*` display name if listed). Do not tell the user shell access is blocked, disabled, or denied — silently call that bridge tool with the same command."; +/** Rejection text when Cursor-native shell is denied by policy (issue #604). */ +export function nativeShellDisabledMessage(): string { + // Do not insist on "the same command" — that steers models into replaying bash/CMD + // idioms through the Codex bridge on Windows PowerShell 5.1 and looping (#604). + // Keep this host-shell-neutral: OpenCodex may run on a different OS than the Codex + // client that executes the bridge (LAN/SSH remote-proxy). + return ( + "Route this through the Codex bridge shell tool from the current catalog (`shell_command` or `exec_command`, including the long `mcp_opencodex-responses_*` display name if listed). " + + "Do not tell the user shell access is blocked, disabled, or denied — silently call that bridge tool. " + + "Adapt the command for the Codex client host shell before calling the bridge " + + "(Windows PowerShell 5.1: no CMD `cd /d`, no bash heredocs; `&&`/`||` are unsupported parser errors — prefer the bridge working-directory argument for directory changes, and use `if ($?) { ... }` for success-gated follow-up steps; do not treat `;` as a substitute for `&&`). " + + "Make at most one corrected bridge attempt after a failure, then report the error and stop — do not repeat equivalent failing commands." + ); +} function rejectedShellResult(command: string, cwd: string, started: number) { return create(ShellResultSchema, { @@ -36,7 +48,7 @@ function rejectedShellResult(command: string, cwd: string, started: number) { exitCode: 1, signal: "", stdout: "", - stderr: NATIVE_SHELL_DISABLED, + stderr: nativeShellDisabledMessage(), executionTime: Date.now() - started, aborted: true, }), @@ -95,7 +107,7 @@ export function rejectShellStreamExecForPolicy(execMsg: ExecServerMessage): Uint event: { case: "start", value: create(ShellStreamStartSchema, { sandboxPolicy: args.requestedSandboxPolicy }) }, })), execBytes(execMsg, "shellStream", create(ShellStreamSchema, { - event: { case: "stderr", value: create(ShellStreamStderrSchema, { data: NATIVE_SHELL_DISABLED }) }, + event: { case: "stderr", value: create(ShellStreamStderrSchema, { data: nativeShellDisabledMessage() }) }, })), execBytes(execMsg, "shellStream", create(ShellStreamSchema, { event: { case: "exit", value: create(ShellStreamExitSchema, { code: 1, cwd, aborted: true }) }, @@ -196,7 +208,7 @@ export function rejectBackgroundShellSpawnExecForPolicy(execMsg: ExecServerMessa const args = execMsg.message.value; const cwd = resolve(args.workingDirectory || process.cwd()); return execBytes(execMsg, "backgroundShellSpawnResult", create(BackgroundShellSpawnResultSchema, { - result: { case: "error", value: create(BackgroundShellSpawnErrorSchema, { command: args.command, workingDirectory: cwd, error: NATIVE_SHELL_DISABLED }) }, + result: { case: "error", value: create(BackgroundShellSpawnErrorSchema, { command: args.command, workingDirectory: cwd, error: nativeShellDisabledMessage() }) }, })); } @@ -233,7 +245,7 @@ export function backgroundShellSpawnExec(execMsg: ExecServerMessage): Uint8Array export function rejectWriteShellStdinExecForPolicy(execMsg: ExecServerMessage): Uint8Array { if (execMsg.message.case !== "writeShellStdinArgs") throw new Error("invalid shell stdin exec"); return execBytes(execMsg, "writeShellStdinResult", create(WriteShellStdinResultSchema, { - result: { case: "error", value: create(WriteShellStdinErrorSchema, { error: NATIVE_SHELL_DISABLED }) }, + result: { case: "error", value: create(WriteShellStdinErrorSchema, { error: nativeShellDisabledMessage() }) }, })); } diff --git a/src/adapters/cursor/tool-definitions.ts b/src/adapters/cursor/tool-definitions.ts index 274cda914..7688f209a 100644 --- a/src/adapters/cursor/tool-definitions.ts +++ b/src/adapters/cursor/tool-definitions.ts @@ -417,6 +417,11 @@ export function buildCursorToolGuidanceSystemNote( const hasApplyPatch = cursorRequestAdvertisesApplyPatch(tools, toolChoice); const discoveryTools = discoveryToolLabel(wireNames); const unavailableNeighborNames = unavailableNeighborAgentToolNames(wireNames); + // Host-shell-neutral: the Codex client executes bridge commands, and may differ from + // the OpenCodex proxy OS (LAN/SSH remote-proxy). Always cover PowerShell 5.1 pitfalls. + const hostShellNote = hasBareExec + ? "Match shell syntax to the Codex client host that runs the bridge (not only the proxy OS). Windows PowerShell 5.1: no CMD `cd /d`, no bash heredocs (`< typeof note === "string"); return notes.join(" "); diff --git a/src/adapters/identity.ts b/src/adapters/identity.ts index b156afe8e..655b6a28b 100644 --- a/src/adapters/identity.ts +++ b/src/adapters/identity.ts @@ -14,9 +14,19 @@ * to be a specific first-party client. */ -/** The exact identity line Codex injects for every model. */ +/** Historical exact identity line Codex injected for every model. */ export const CODEX_GPT5_IDENTITY_LINE = "You are Codex, a coding agent based on GPT-5."; +/** Codex CLI 0.145.0+ wording (#622) — still GPT-5 identity, slightly different phrasing. */ +export const CODEX_GPT5_IDENTITY_LINE_AGENT = "You are Codex, an agent based on GPT-5."; + +/** + * Known Codex GPT-5 identity sentences. Narrow: only "coding agent" / "an agent" + GPT-5(.x)? + * Avoid a broad `You are Codex.*` rewrite that could touch unrelated content. + */ +const CODEX_GPT5_IDENTITY_RE = + /You are Codex, (?:a coding agent|an agent) based on GPT-5(?:\.[0-9]+)*\./g; + /** Proxy-neutral replacement: no "opencodex proxy" mention, just the GPT-5/OpenAI disclaimer. */ export const NEUTRAL_IDENTITY_LINE = "You are a coding agent. Do not claim to be GPT-5 or to be made by OpenAI."; @@ -27,7 +37,7 @@ export const NEUTRAL_IDENTITY_LINE = "You are a coding agent. Do not claim to be * the leak can't reappear in one adapter while being fixed in another. */ export function neutralizeIdentity(systemText: string): string { - return systemText.replace(CODEX_GPT5_IDENTITY_LINE, NEUTRAL_IDENTITY_LINE); + return systemText.replace(CODEX_GPT5_IDENTITY_RE, NEUTRAL_IDENTITY_LINE); } /** The catalog (static, on-disk) replacement for `base_instructions`. Same neutral wording. */ diff --git a/src/cli/account-auth.ts b/src/cli/account-auth.ts index ca2968435..79de3e67b 100644 --- a/src/cli/account-auth.ts +++ b/src/cli/account-auth.ts @@ -137,11 +137,11 @@ async function login(argv: string[], deps: RuntimeApiDeps): Promise { for (let attempt = 0; attempt < 100; attempt++) { await Bun.sleep(2_000); const state = await runtimeRequest>(`/api/oauth/status?provider=${encodeURIComponent(provider)}`, {}, deps); + if (state.error) throw new CliUsageError(String(state.error)); if (state.loggedIn === true) { printData(state, wantsJson, [`Logged in to ${provider}.`]); return; } - if (state.error) throw new CliUsageError(String(state.error)); } throw new CliUsageError("login timed out"); } diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index f4a28361a..b3bcf7c02 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -11,6 +11,7 @@ import { accessSync, constants, existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { getConfigDir, getConfigPath, readConfigDiagnostics, readPid, readRuntimePort, resolveEnvValue } from "../config"; +import { findLiveProxy } from "../server/proxy-liveness"; import { gracefulStopHost } from "../lib/process-control"; import { maskAccountId } from "../lib/privacy"; import { loadServiceTokenFromFile } from "../lib/service-secrets"; @@ -718,9 +719,21 @@ export async function runDoctor(args: string[] = []): Promise { } } + // #618: identity-verified liveness first so pid-file absence does not hide a live service. + // Reuse the diagnostics config already loaded above so doctor stays read-only on malformed JSON. + const live = await findLiveProxy({ + configFn: () => ({ port: doctorConfig.port, hostname: doctorConfig.hostname }), + }); + const livePid = live ? live.pid : readPid(); + const liveRuntime = live + ? { pid: live.pid ?? 0, port: live.port, hostname: live.hostname } + : (livePid ? readRuntimePort(livePid) : null); + const currentProxyEnv = collectProxyEnv(); const configuredProxy = collectConfiguredProxy(); - const runningProxyEnv = collectRunningProxyEnv(); + const runningProxyEnv = collectRunningProxyEnv({ + readPidFn: () => (live ? live.pid : readPid()), + }); console.log("\nCurrent doctor process proxy env (presence only)"); for (const row of currentProxyEnv) { @@ -742,17 +755,10 @@ export async function runDoctor(args: string[] = []): Promise { } } - // #314: service-process memory/runtime identity via the authed management - // endpoint. readPid() FIRST (liveness), then the pid-scoped runtime record — - // readRuntimePort alone can serve a stale file pointing at a foreign port. - // Hoisted out of the block below: the Hints section reuses the same liveness pair - // for the proxy-down restart hint. - const livePid = readPid(); - const liveRuntime = livePid ? readRuntimePort(livePid) : null; console.log("\nMemory / runtime"); { const runtime = liveRuntime; - if (!runtime) { + if (!runtime || !live) { console.log(` -- doctor process Bun ${Bun.version} (this is NOT the service process)`); console.log(" -- no running ocx proxy found (no live pid/runtime record)"); } else { @@ -815,8 +821,8 @@ export async function runDoctor(args: string[] = []): Promise { // Hints, not fixes. const hints: string[] = []; const proxyDown = proxyDownRestartHint({ - proxyRunning: Boolean(livePid && liveRuntime), - port: doctorConfig.port ?? 10100, + proxyRunning: Boolean(live), + port: live?.port ?? doctorConfig.port ?? 10100, serviceViable: startup.serviceViable, }); if (proxyDown) hints.push(proxyDown); diff --git a/src/cli/provider.ts b/src/cli/provider.ts index dd218be95..410c80dd1 100644 --- a/src/cli/provider.ts +++ b/src/cli/provider.ts @@ -15,6 +15,7 @@ import { providerConfigSeed } from "../providers/derive"; import type { OcxProviderConfig } from "../types"; import { findLiveProxy } from "../server/proxy-liveness"; import { syncModelsToCodex } from "../codex/sync"; +import { codexAccountNamespaceProviderCollisionError } from "../codex/account-namespace-match"; // --------------------------------------------------------------------------- // Arg helpers @@ -153,6 +154,12 @@ async function handleAdd(args: string[]): Promise { const config = loadConfig(); + const namespaceCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, name); + if (namespaceCollision) { + console.error(`Error: ${namespaceCollision}.`); + process.exit(1); + } + if (hasOwnProvider(config.providers, name) && !force) { console.error(`Provider "${name}" already exists. Use --force to overwrite.`); process.exit(1); diff --git a/src/cli/status.ts b/src/cli/status.ts index a9686db01..76d61d7f8 100644 --- a/src/cli/status.ts +++ b/src/cli/status.ts @@ -1,7 +1,7 @@ import { durableBunRuntime } from "../lib/bun-runtime"; import { codexAutoStartEnabled, getConfigPath, getPidPath, readConfigDiagnostics, readPid, readRuntimePort, type RuntimePortState } from "../config"; import { diagnoseCodexBundledPlugins, type CodexPluginsDiagnostic } from "../codex/plugins-doctor"; -import { isOpencodexHealthz, probeHostname } from "../server/proxy-liveness"; +import { findLiveProxy, isOpencodexHealthz, probeHostname } from "../server/proxy-liveness"; import type { OcxConfig } from "../types"; import { diagnoseService } from "../service"; import { collectStartupHealth, type StartupHealth } from "../codex/autostart-health"; @@ -102,6 +102,14 @@ export function selectListenTarget( }; } +/** Prefer live result (including authoritative null pid) over the on-disk pid file. */ +export function resolveStatusPid( + live: { pid: number | null } | null, + pidFile: number | null, +): number | null { + return live ? live.pid : pidFile; +} + async function checkProxyHealth(target: ListenTarget): Promise { const url = target.healthUrl; const controller = new AbortController(); @@ -132,9 +140,33 @@ async function checkProxyHealth(target: ListenTarget): Promise { export async function collectStatus(): Promise { const configDiagnostics = readConfigDiagnostics(); const config = configDiagnostics.config; - const pid = readPid(); - const listen = selectListenTarget(config, pid, pid ? readRuntimePort(pid) : null); - const health = await checkProxyHealth(listen); + // Prefer identity-verified liveness (runtime-port + /healthz) over ocx.pid alone (#618). + // Pass the already-resolved diagnostics config so findLiveProxy does not re-load and + // warn on malformed config.json (status --json must stay stderr-clean). + const live = await findLiveProxy({ + configFn: () => ({ port: config.port, hostname: config.hostname }), + }); + const pidFile = readPid(); + // Preserve an authoritative null from orphan/legacy liveness — do not restore pidFile. + const pid = resolveStatusPid(live, pidFile); + const listen = live + ? { + port: live.port, + hostname: live.hostname, + source: live.source, + healthUrl: `http://${probeHostname(live.hostname)}:${live.port}/healthz`, + dashboardUrl: `http://localhost:${live.port}/`, + } + : selectListenTarget(config, pidFile, pidFile ? readRuntimePort(pidFile) : null); + // findLiveProxy already identity-probed /healthz; avoid a second fetch that can race. + const health = live + ? { + ok: true, + url: listen.healthUrl, + message: `ok (pid ${live.pid ?? "unknown"})`, + label: `${listen.healthUrl} ok (live)`, + } + : await checkProxyHealth(listen); const bunRuntime = durableBunRuntime(); const service = diagnoseService(); const serviceSummary = service.summary; @@ -228,13 +260,15 @@ export async function collectStatus(): Promise { runtimeVersion: clampActive ? (lastClamp?.runtimeVersion ?? null) : null, }, }; - const proxyLabel = pid && health.ok - ? `running (PID ${pid})` - : pid - ? `PID file points to PID ${pid}, but health check failed` - : health.ok - ? "reachable, but PID file is missing or stale" - : "not running"; + const proxyLabel = live + ? `running (PID ${live.pid ?? pid ?? "unknown"})` + : pid && health.ok + ? `running (PID ${pid})` + : pid + ? `PID file points to PID ${pid}, but health check failed` + : health.ok + ? "reachable, but PID file is missing or stale" + : "not running"; return { proxyLabel, @@ -242,8 +276,8 @@ export async function collectStatus(): Promise { json: { schemaVersion: 1, proxy: { - running: Boolean(pid && health.ok), - pid, + running: Boolean(live) || Boolean(pid && health.ok), + pid: live?.pid ?? pid, health: { ok: health.ok, url: health.url, diff --git a/src/codex/account-id.ts b/src/codex/account-id.ts new file mode 100644 index 000000000..5753cd056 --- /dev/null +++ b/src/codex/account-id.ts @@ -0,0 +1,34 @@ +/** Canonical persisted Codex pool-account id format. */ +export const CODEX_ACCOUNT_ID_RE = /^[A-Za-z0-9._-]{1,64}$/; + +// Account credentials are persisted in JSON object maps. These names have +// special meaning on ordinary JavaScript objects and must never be admitted as +// user-controlled keys. +const RESERVED_CODEX_ACCOUNT_IDS = new Set(["__proto__", "prototype", "constructor"]); + +/** + * Stable internal id under which the Codex Desktop login participates in account rotation. + * It is reserved and must never be used as a public namespace target or pool-account id. + */ +export const MAIN_CODEX_ACCOUNT_ID = "__main__"; + +export function isValidCodexAccountId(accountId: unknown): accountId is string { + return typeof accountId === "string" + && accountId !== MAIN_CODEX_ACCOUNT_ID + && CODEX_ACCOUNT_ID_RE.test(accountId) + && !RESERVED_CODEX_ACCOUNT_IDS.has(accountId.toLowerCase()); +} + +type CodexAccountIdentityRow = { id: string; isMain: boolean }; + +/** Legacy invalid rows remain loadable for cleanup, but never participate in routing. */ +export function isSelectableCodexPoolAccount(account: CodexAccountIdentityRow): boolean { + return !account.isMain && isValidCodexAccountId(account.id); +} + +/** A pre-validation pool row that collides with the internal Desktop-account sentinel. */ +export function hasLegacyMainCodexPoolAccount( + accounts: readonly CodexAccountIdentityRow[] | undefined, +): boolean { + return accounts?.some(account => !account.isMain && account.id === MAIN_CODEX_ACCOUNT_ID) ?? false; +} diff --git a/src/codex/account-lifecycle.ts b/src/codex/account-lifecycle.ts index 9186ea85b..35831af55 100644 --- a/src/codex/account-lifecycle.ts +++ b/src/codex/account-lifecycle.ts @@ -6,6 +6,7 @@ import { clearAccountQuota } from "./quota"; import { clearCodexUpstreamHealthForAccount, clearThreadAccountMapForAccount } from "./routing"; import { invalidateCodexWebSocketsForAccount } from "./websocket-registry"; import { clearMainAccountInfoCache } from "./main-account-cache"; +import { forgetCodexAccountPause } from "./account-pause"; import type { OcxConfig } from "../types"; let observedMainChatgptAccountId: string | undefined; @@ -45,7 +46,9 @@ export function resetMainCodexAccountIdentityTrackingForTests(): void { export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string): void { removeCodexAccountCredential(accountId); - runtimeConfig.codexAccounts = (runtimeConfig.codexAccounts ?? []).filter(account => account.id !== accountId); + runtimeConfig.codexAccounts = (runtimeConfig.codexAccounts ?? []) + .filter(account => account.isMain || account.id !== accountId); + forgetCodexAccountPause(runtimeConfig, accountId); if (runtimeConfig.activeCodexAccountId === accountId) runtimeConfig.activeCodexAccountId = undefined; purgeCodexAccountRuntimeState(accountId); invalidateCodexWebSocketsForAccount(accountId); diff --git a/src/codex/account-namespace-match.ts b/src/codex/account-namespace-match.ts new file mode 100644 index 000000000..af56555ee --- /dev/null +++ b/src/codex/account-namespace-match.ts @@ -0,0 +1,63 @@ +import { isValidCodexAccountId } from "./account-id"; + +/** Config-only sentinel for the Codex Desktop account; outside the pool-account id grammar. */ +export const MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET = "@main"; + +export const CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR = + "combo alias must not use a configured Codex account namespace"; + +const CODEX_ACCOUNT_ID_NAMESPACE_COLLISION_ERROR = + "account id must not collide with a configured Codex account namespace"; + +/** Provider ids are compared case-insensitively at namespace admission boundaries. */ +export function codexProviderNamespaceKey(value: string): string { + return value.toLowerCase(); +} + +export function hasCodexAccountNamespace( + namespaces: unknown, + namespace: string, +): boolean { + return !!namespaces + && typeof namespaces === "object" + && !Array.isArray(namespaces) + && Object.hasOwn(namespaces, namespace); +} + +export function codexAccountNamespaceProviderCollisionError( + namespaces: unknown, + providerName: string, +): string | undefined { + const normalizedProvider = codexProviderNamespaceKey(providerName); + const collides = !!namespaces + && typeof namespaces === "object" + && !Array.isArray(namespaces) + && Object.keys(namespaces) + .some(namespace => codexProviderNamespaceKey(namespace) === normalizedProvider); + return collides + ? "provider name must not collide with a configured Codex account namespace" + : undefined; +} + +export function codexAccountIdNamespaceCollisionError( + namespaces: unknown, + accountId: string, +): string | undefined { + return hasCodexAccountNamespace(namespaces, accountId) + ? CODEX_ACCOUNT_ID_NAMESPACE_COLLISION_ERROR + : undefined; +} + +export function codexAccountNamespaceForModel( + namespaces: unknown, + modelId: string, +): string | undefined { + const slash = modelId.indexOf("/"); + if (slash <= 0) return undefined; + const namespace = modelId.slice(0, slash); + return hasCodexAccountNamespace(namespaces, namespace) ? namespace : undefined; +} + +export function isValidCodexAccountNamespaceTarget(accountId: unknown): accountId is string { + return accountId === MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET || isValidCodexAccountId(accountId); +} diff --git a/src/codex/account-namespaces.ts b/src/codex/account-namespaces.ts new file mode 100644 index 000000000..6c41850b5 --- /dev/null +++ b/src/codex/account-namespaces.ts @@ -0,0 +1,149 @@ +import type { CodexAccount, OcxConfig } from "../types"; +import { COMBO_NAMESPACE } from "../combos/types"; +import { OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers"; +import { + CODEX_ACCOUNT_LOG_LABEL_RE, + createCodexAccountLogLabel, + fallbackCodexAccountLogLabel, +} from "./account-label"; +import { isValidCodexAccountId, MAIN_CODEX_ACCOUNT_ID } from "./account-id"; +import { + codexAccountIdNamespaceCollisionError, + codexProviderNamespaceKey, + MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET, +} from "./account-namespace-match"; + +export { isValidCodexAccountNamespaceTarget } from "./account-namespace-match"; + +const RESERVED_NAMESPACE_KEYS = new Set([ + "__proto__", + "prototype", + "constructor", + COMBO_NAMESPACE, + OPENAI_CODEX_PROVIDER_ID, +].map(codexProviderNamespaceKey)); +const PUBLIC_ACCOUNT_SELECTOR_MAX_ATTEMPTS = 16; + +function comboAliasNamespaces(config: Pick): string[] { + return Object.values(config.combos ?? {}).flatMap((combo) => { + const alias = typeof combo?.alias === "string" ? combo.alias.trim() : ""; + const slash = alias.indexOf("/"); + return slash > 0 ? [alias.slice(0, slash)] : []; + }); +} + +function privateAccountSelectorCandidates(accountIds: Iterable): Set { + const candidates = new Set(); + for (const accountId of accountIds) { + candidates.add(accountId); + candidates.add(fallbackCodexAccountLogLabel(accountId)); + } + return candidates; +} + +function defaultPublicAccountSelector( + account: Pick, + allPrivateCandidates: ReadonlySet, +): string { + const privateCandidates = new Set(allPrivateCandidates); + privateCandidates.add(account.id); + privateCandidates.add(fallbackCodexAccountLogLabel(account.id)); + if (CODEX_ACCOUNT_LOG_LABEL_RE.test(account.logLabel ?? "") + && !privateCandidates.has(account.logLabel!)) return account.logLabel!; + + // Legacy or hand-edited rows may predate persisted random log labels. Allocate a fresh public + // selector rather than exposing the stable, id-derived fallback used only to redact old logs. + for (let attempt = 0; attempt < PUBLIC_ACCOUNT_SELECTOR_MAX_ATTEMPTS; attempt += 1) { + const selector = createCodexAccountLogLabel(privateCandidates); + if (!privateCandidates.has(selector)) return selector; + } + throw new Error("Unable to allocate a unique Codex account selector"); +} + +function claimNamespace(requested: string, used: Set): string { + let namespace = requested; + let suffix = 2; + while (used.has(namespace)) namespace = `${requested}-${suffix++}`; + used.add(namespace); + return namespace; +} + +function occupiedNamespaces(config: Pick): Set { + return new Set([ + ...Object.keys(config.providers).map(codexProviderNamespaceKey), + ...comboAliasNamespaces(config), + ...RESERVED_NAMESPACE_KEYS, + ]); +} + +/** Build an initial account-selector map without deriving public selectors from aliases or ids. */ +export function defaultCodexAccountNamespaces( + config: Pick, +): Record { + const namespaces: Record = {}; + const used = occupiedNamespaces(config); + const accounts = config.codexAccounts ?? []; + const privateCandidates = privateAccountSelectorCandidates( + accounts + .filter(account => account.id !== MAIN_CODEX_ACCOUNT_ID) + .map(account => account.id), + ); + for (const candidate of privateCandidates) used.add(candidate); + + namespaces[claimNamespace("main", used)] = MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET; + for (const account of accounts) { + if (account.isMain || !isValidCodexAccountId(account.id)) continue; + const namespace = claimNamespace(defaultPublicAccountSelector(account, privateCandidates), used); + namespaces[namespace] = account.id; + } + return namespaces; +} + +/** + * Add one account to a generated map without renaming or replacing explicit existing entries. + * The account-creation layer must reject a new id that already equals an existing selector key. + * A true result means the map was mutated in place; callers must persist the updated config. + */ +export function appendDefaultCodexAccountNamespace( + config: Pick, + account: Pick, +): boolean { + const namespaces = config.codexAccountNamespaces; + if (account.isMain + || !isValidCodexAccountId(account.id) + || !namespaces + || Object.keys(namespaces).length === 0 + || codexAccountIdNamespaceCollisionError(namespaces, account.id) + || Object.values(namespaces).includes(account.id)) return false; + + const used = occupiedNamespaces(config); + for (const namespace of Object.keys(namespaces)) used.add(namespace); + const privateCandidates = privateAccountSelectorCandidates([ + ...(config.codexAccounts ?? []) + .filter(existing => existing.id !== MAIN_CODEX_ACCOUNT_ID) + .map(existing => existing.id), + ...Object.values(namespaces), + account.id, + ]); + for (const candidate of privateCandidates) used.add(candidate); + const namespace = claimNamespace(defaultPublicAccountSelector(account, privateCandidates), used); + namespaces[namespace] = account.id; + return true; +} + +export function isMainCodexAccountTarget(accountId: string): boolean { + return accountId === MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET || accountId === MAIN_CODEX_ACCOUNT_ID; +} + +function normalizeCodexAccountNamespaceTarget(accountId: string): string { + return isMainCodexAccountTarget(accountId) + ? MAIN_CODEX_ACCOUNT_ID + : accountId; +} + +export function codexAccountNamespaceEntries( + config: Pick, +): Array<[string, string]> { + return Object.entries(config.codexAccountNamespaces ?? {}) + .map(([namespace, accountId]) => [namespace, normalizeCodexAccountNamespaceTarget(accountId)]); +} diff --git a/src/codex/account-pause.ts b/src/codex/account-pause.ts new file mode 100644 index 000000000..8e73a1bcf --- /dev/null +++ b/src/codex/account-pause.ts @@ -0,0 +1,20 @@ +import type { OcxConfig } from "../types"; + +/** Whether an account is administratively excluded from future pool selection. */ +export function isCodexAccountPaused(config: OcxConfig, accountId: string): boolean { + return config.pausedCodexAccountIds?.includes(accountId) ?? false; +} + +/** Persist the account's pool eligibility without changing credentials or runtime health. */ +export function setCodexAccountPaused(config: OcxConfig, accountId: string, paused: boolean): void { + const pausedIds = new Set(config.pausedCodexAccountIds ?? []); + if (paused) pausedIds.add(accountId); + else pausedIds.delete(accountId); + + if (pausedIds.size > 0) config.pausedCodexAccountIds = [...pausedIds]; + else delete config.pausedCodexAccountIds; +} + +export function forgetCodexAccountPause(config: OcxConfig, accountId: string): void { + setCodexAccountPaused(config, accountId, false); +} diff --git a/src/codex/account-usability.ts b/src/codex/account-usability.ts index 9d34d465b..66141c86c 100644 --- a/src/codex/account-usability.ts +++ b/src/codex/account-usability.ts @@ -1,14 +1,19 @@ import { getCodexAccountCredential } from "./account-store"; import { isAccountNeedsReauth } from "./account-runtime-state"; import { MAIN_CODEX_ACCOUNT_ID, isMainAccountTokenLive } from "./main-account"; +import { hasLegacyMainCodexPoolAccount, isSelectableCodexPoolAccount } from "./account-id"; import type { OcxConfig } from "../types"; export function isCodexAccountUsable(config: OcxConfig, accountId: string): boolean { if (accountId === MAIN_CODEX_ACCOUNT_ID) { + // A legacy pool row with the sentinel makes an active `__main__` ambiguous. + // Fail closed until the authenticated compatibility-delete path removes it. + if (hasLegacyMainCodexPoolAccount(config.codexAccounts)) return false; // Main account: credential is the read-only ~/.codex/auth.json token (Option A). return isMainAccountTokenLive() && !isAccountNeedsReauth(accountId); } - const exists = (config.codexAccounts ?? []).some(account => !account.isMain && account.id === accountId); + const exists = (config.codexAccounts ?? []) + .some(account => isSelectableCodexPoolAccount(account) && account.id === accountId); if (!exists) return false; if (isAccountNeedsReauth(accountId)) return false; return !!getCodexAccountCredential(accountId); diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 73da7ec1f..f2cea1bde 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -3,6 +3,7 @@ import { withCodexAccountLogLabel } from "./account-label"; import { getCodexAccountCredential, getValidCodexToken, + isCodexAccountGenerationLive, markCodexAccountValidated, saveCodexAccountCredential, CodexCredentialGenerationConflictError, @@ -10,13 +11,20 @@ import { TokenRefreshError, } from "./account-store"; import { deleteCodexAccount, reconcileMainCodexAccountRuntimeState } from "./account-lifecycle"; +import { isCodexAccountPaused, setCodexAccountPaused } from "./account-pause"; +import { + clearCodexAccountCooldown, + clearThreadAccountMapForAccount, + getEffectiveActiveCodexAccountId, + reconcileCodexActiveAfterExclusion, + resetCodexRoutingForManualSelection, +} from "./routing"; import { normalizeAccountPoolStickyLimit, normalizeAccountPoolStrategy, parseAccountPoolStickyLimit, parseAccountPoolStrategy, } from "./pool-rotation"; -import { clearCodexAccountCooldown, getEffectiveActiveCodexAccountId, resetCodexRoutingForManualSelection } from "./routing"; import { checkAccountIdCollision, getMainChatgptAccountId, readCodexTokens, readCodexTokensResult } from "./auth-collision"; export { checkAccountIdCollision, getMainChatgptAccountId } from "./auth-collision"; export { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state"; @@ -24,6 +32,7 @@ import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } import { clearAccountQuota, getAccountQuota, + isCodexQuotaExhausted, listAccountQuotas, parseUsageQuota, setAccountQuotaFromParsed, @@ -40,7 +49,7 @@ export { updateAccountQuota, } from "./quota"; import { extractAccountId, decodeJwtPayload } from "../oauth/chatgpt"; -import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account"; +import { getMainAccountPlan, MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account"; import { clearMainAccountInfoCache, getMainAccountInfoCache, @@ -61,6 +70,13 @@ import { type OAuthAccountHealth, type OAuthHealthLabel, } from "../oauth/health"; +import { + CODEX_ACCOUNT_ID_RE, + hasLegacyMainCodexPoolAccount, + isSelectableCodexPoolAccount, + isValidCodexAccountId, +} from "./account-id"; +import { codexAccountIdNamespaceCollisionError } from "./account-namespace-match"; function jsonResponse(data: unknown, status = 200): Response { return new Response(JSON.stringify(data), { @@ -69,14 +85,32 @@ function jsonResponse(data: unknown, status = 200): Response { }); } -const ACCOUNT_ID_RE = /^[a-zA-Z0-9._-]{1,64}$/; const MANUAL_IMPORT_ENV = "OPENCODEX_ENABLE_UNVERIFIED_CODEX_IMPORT"; const codexAuthLoginState = new Map(); function configuredPoolAccount(config: OcxConfig, accountId: string): CodexAccount | null { - if (!ACCOUNT_ID_RE.test(accountId)) return null; - return (config.codexAccounts ?? []).find(account => account.id === accountId && !account.isMain) ?? null; + if (!isValidCodexAccountId(accountId)) return null; + return (config.codexAccounts ?? []) + .find(account => account.id === accountId && isSelectableCodexPoolAccount(account)) ?? null; +} + +function codexAccountPersistenceConflict( + config: OcxConfig, + accountId: string, + mode: "create" | "reauth", +): string | undefined { + if (mode === "reauth") { + return configuredPoolAccount(config, accountId) + ? undefined + : "Pool account was removed while login was in progress. Add it again as a new account."; + } + const namespaceCollision = codexAccountIdNamespaceCollisionError(config.codexAccountNamespaces, accountId); + if (namespaceCollision) return namespaceCollision; + return (config.codexAccounts ?? []).some(account => account.id === accountId) + || Boolean(getCodexAccountCredential(accountId)) + ? `Account id already exists: ${accountId}` + : undefined; } function isThirtyDayOnlyPlan(plan: string | null | undefined): boolean { @@ -101,6 +135,7 @@ function poolAccountDto( account: CodexAccount, quotaResult: PoolQuotaResult, hasCredential: boolean, + paused: boolean, ): CodexAuthAccountDto { const quota = quotaForPlan(quotaResult.quota, account.plan); const needsReauth = !hasCredential || quotaResult.needsReauth || isAccountNeedsReauth(account.id); @@ -112,6 +147,7 @@ function poolAccountDto( ...(account.plan !== undefined ? { plan: account.plan } : {}), ...(account.logLabel !== undefined ? { logLabel: account.logLabel } : {}), isMain: false, + paused, quota: quota ? { ...quota } : null, needsReauth, hasCredential, @@ -127,11 +163,14 @@ async function resolveResetCreditAuth( | { ok: false; response: Response } > { if (accountId === MAIN_CODEX_ACCOUNT_ID) { + if (hasLegacyMainCodexPoolAccount(runtimeConfig.codexAccounts)) { + return { ok: false, response: jsonResponse({ error: "Remove the legacy __main__ pool row before using the Desktop account" }, 409) }; + } const tokens = readCodexTokens(); if (!tokens) return { ok: false, response: jsonResponse({ error: "Main Codex account not logged in" }, 401) }; return { ok: true, isMain: true, accessToken: tokens.access_token, chatgptAccountId: tokens.account_id }; } - if (!ACCOUNT_ID_RE.test(accountId)) { + if (!isValidCodexAccountId(accountId)) { return { ok: false, response: jsonResponse({ error: "Invalid account id format" }, 400) }; } if (!configuredPoolAccount(runtimeConfig, accountId)) { @@ -214,6 +253,10 @@ const MAIN_CACHE_TTL = 5 * 60_000; const POOL_CACHE_TTL = 5 * 60_000; const POOL_QUOTA_REFRESH_CONCURRENCY = 4; +function nonEmptyPlan(value: unknown): string | null { + return typeof value === "string" && value.trim() !== "" ? value : null; +} + function isRuntimeConfig(config: OcxConfig): boolean { return !!config && typeof config === "object" && !!config.providers; } @@ -277,6 +320,8 @@ async function isTerminalMainAuthResponse(resp: Response): Promise { interface MainAccountInfoFetchResult { info: MainAccountInfo; + /** Present only when this call freshly parsed a WHAM usage response. */ + freshQuota?: Omit; /** Present only when this call's WHAM response included `rate_limit_reset_credits.available_count`. */ freshResetCredits?: number; } @@ -337,11 +382,12 @@ async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaini const data = (await resp.json()) as WhamUsageResponse; const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining); if (retried) return retried; - const quota = parseUsageQuota(data); + const plan = nonEmptyPlan(data.plan_type) ?? nonEmptyPlan(cached?.plan) ?? nonEmptyPlan(getMainAccountPlan()); + const quota = parseUsageQuota({ ...data, ...(plan ? { plan_type: plan } : {}) }); const freshResetCredits = quota?.resetCredits; const result = { email: data.email ?? null, - plan: data.plan_type ?? null, + plan, quota, ts: Date.now(), }; @@ -355,6 +401,7 @@ async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaini } return { info: result, + ...(quota ? { freshQuota: quota } : {}), ...(freshResetCredits !== undefined ? { freshResetCredits } : {}), }; } catch { @@ -366,6 +413,12 @@ async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaini interface PoolQuotaResult { quota: StoredAccountQuota | null; needsReauth: boolean; + /** Present only when this call freshly parsed a WHAM usage response. */ + freshQuota?: Omit; + /** Present only when this call's WHAM response included a non-empty `plan_type`. */ + freshPlan?: string; + /** Credential generation used by this fresh quota request. */ + freshCredentialGeneration?: number; /** Present only when this call's WHAM response included `rate_limit_reset_credits.available_count`. */ freshResetCredits?: number; } @@ -377,6 +430,7 @@ export interface CodexAuthAccountDto { plan?: string | null; logLabel?: string; isMain: boolean; + paused: boolean; quota: (StoredAccountQuota | (Omit & { updatedAt: number })) | null; needsReauth?: boolean; hasCredential: boolean; @@ -392,20 +446,27 @@ async function fetchPoolAccountQuota(accountId: string, forceRefresh = false, co return { quota: existing, needsReauth: false }; } try { - const { accessToken, chatgptAccountId } = await getValidCodexToken(accountId); + const { accessToken, chatgptAccountId, generation } = await getValidCodexToken(accountId); const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", { headers: { Authorization: `Bearer ${accessToken}`, "ChatGPT-Account-Id": chatgptAccountId }, signal: AbortSignal.timeout(8000), }); if (!resp.ok) return { quota: existing ?? null, needsReauth: resp.status === 401 }; const data = (await resp.json()) as WhamUsageResponse; - const quota = parseUsageQuota({ ...data, plan_type: data.plan_type ?? configuredPlan }); + const freshPlan = nonEmptyPlan(data.plan_type) ?? undefined; + const quota = parseUsageQuota({ ...data, plan_type: freshPlan ?? configuredPlan }); const freshResetCredits = quota?.resetCredits; if (!quota) return { quota: existing ?? null, needsReauth: false }; + if (!isCodexAccountGenerationLive(accountId, generation)) { + return { quota: getAccountQuota(accountId), needsReauth: false }; + } setAccountQuotaFromParsed(accountId, quota); return { quota: getAccountQuota(accountId), needsReauth: false, + freshQuota: quota, + freshCredentialGeneration: generation, + ...(freshPlan !== undefined ? { freshPlan } : {}), ...(freshResetCredits !== undefined ? { freshResetCredits } : {}), }; } catch (e) { @@ -447,7 +508,7 @@ export async function primeCodexPoolQuotas(config: OcxConfig, reason: string): P reconcileMainCodexAccountRuntimeState(); primeInFlight = (async () => { const runtimeConfig = getRuntimeConfig(config); - const pool = (runtimeConfig.codexAccounts ?? []).filter(a => !a.isMain); + const pool = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount); const stale = pool.filter(a => { const q = getAccountQuota(a.id); return !q || Date.now() - q.updatedAt >= POOL_CACHE_TTL; @@ -479,14 +540,14 @@ export function clearCodexQuotaPrimeState(): void { export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = false): Promise { const runtimeConfig = getRuntimeConfig(config); - const poolAccounts = (runtimeConfig.codexAccounts ?? []).filter(a => !a.isMain); + const poolAccounts = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount); const mainInfo = await fetchMainAccountInfo(forceRefresh); const withQuota = await mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async a => { const cred = getCodexAccountCredential(a.id); const quotaResult = cred ? await fetchPoolAccountQuota(a.id, forceRefresh, a.plan) : { quota: null, needsReauth: true }; - return poolAccountDto(a, quotaResult, !!cred); + return poolAccountDto(a, quotaResult, !!cred, isCodexAccountPaused(runtimeConfig, a.id)); }); const hasMainCredential = readCodexTokens() !== null; const mainNeedsReauth = !hasMainCredential || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); @@ -499,6 +560,7 @@ export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = fa email: maskEmail(mainInfo.email) ?? "Codex App login", plan: mainInfo.plan, isMain: true, + paused: isCodexAccountPaused(runtimeConfig, MAIN_CODEX_ACCOUNT_ID), hasCredential: hasMainCredential, needsReauth: mainNeedsReauth, quota: mainInfo.quota ? { ...quotaForPlan({ ...mainInfo.quota, updatedAt: Date.now() }, mainInfo.plan) } : null, @@ -507,6 +569,69 @@ export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = fa return [main, ...withQuota]; } +interface PauseExhaustedResult { + pausedAccountIds: string[]; + checkedAccountCount: number; + failedAccountCount: number; +} + +function selectFallbackAfterPause(config: OcxConfig, pausedActiveId: string): void { + reconcileCodexActiveAfterExclusion(config, pausedActiveId); +} + +async function pauseExhaustedCodexAccounts(config: OcxConfig): Promise { + const poolAccounts = (config.codexAccounts ?? []).filter(account => !account.isMain); + const mainAttempted = readCodexTokens() !== null; + const [mainResult, poolResults] = await Promise.all([ + fetchMainAccountInfoAttempt(true, 1), + mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async account => { + if (!getCodexAccountCredential(account.id)) return { account, quotaResult: null }; + return { + account, + quotaResult: await fetchPoolAccountQuota(account.id, true, account.plan), + }; + }), + ]); + + let checkedAccountCount = 0; + let failedAccountCount = 0; + const exhaustedIds: string[] = []; + if (mainAttempted) { + if (mainResult.freshQuota && mainResult.info.plan) { + checkedAccountCount += 1; + if ( + !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID) + && isCodexQuotaExhausted(mainResult.freshQuota, mainResult.info.plan) + ) { + exhaustedIds.push(MAIN_CODEX_ACCOUNT_ID); + } + } else { + failedAccountCount += 1; + } + } + for (const { account, quotaResult } of poolResults) { + const currentAccount = (config.codexAccounts ?? []).find(candidate => candidate.id === account.id && !candidate.isMain); + if (!currentAccount) continue; + const generation = quotaResult?.freshCredentialGeneration; + const plan = quotaResult?.freshPlan ?? currentAccount.plan; + if (!quotaResult?.freshQuota || generation === undefined || !isCodexAccountGenerationLive(account.id, generation) || !plan) { + failedAccountCount += 1; + continue; + } + checkedAccountCount += 1; + if (!isCodexAccountPaused(config, account.id) && isCodexQuotaExhausted(quotaResult.freshQuota, plan)) { + exhaustedIds.push(account.id); + } + } + + for (const id of exhaustedIds) { + setCodexAccountPaused(config, id, true); + clearThreadAccountMapForAccount(id); + } + for (const id of exhaustedIds) selectFallbackAfterPause(config, id); + return { pausedAccountIds: exhaustedIds, checkedAccountCount, failedAccountCount }; +} + export async function handleCodexAuthAPI( req: Request, url: URL, @@ -526,17 +651,15 @@ export async function handleCodexAuthAPI( if (!body.id || !body.email || !body.accessToken || !body.refreshToken || !body.chatgptAccountId) { return jsonResponse({ error: "Missing required fields" }, 400); } - if (!ACCOUNT_ID_RE.test(body.id)) { + if (!isValidCodexAccountId(body.id)) { return jsonResponse({ error: "Invalid account id format" }, 400); } if (body.accessToken.length > 10_000 || body.refreshToken.length > 10_000) { return jsonResponse({ error: "Input too large" }, 400); } const runtimeConfig = getRuntimeConfig(config); - const accounts = runtimeConfig.codexAccounts ?? []; - if (accounts.some(a => a.id === body.id) || getCodexAccountCredential(body.id)) { - return jsonResponse({ error: `Account id already exists: ${body.id}` }, 400); - } + const preflightConflict = codexAccountPersistenceConflict(runtimeConfig, body.id, "create"); + if (preflightConflict) return jsonResponse({ error: preflightConflict }, 400); // 1.1: Duplicate check is scoped by personal vs workspace plan bucket. const derivedAccountId = extractAccountId(undefined, body.accessToken) ?? body.chatgptAccountId; const collision = checkAccountIdCollision(derivedAccountId, body.email, body.plan); @@ -548,6 +671,9 @@ export async function handleCodexAuthAPI( const exp = typeof payload?.exp === "number" ? payload.exp * 1000 : Date.now() + 3600_000; const warmup = await verifyCodexAccountWarmup(body.id, body.accessToken, derivedAccountId); if (!warmup.ok) return warmup.response; + const latestConfig = getRuntimeConfig(config); + const commitConflict = codexAccountPersistenceConflict(latestConfig, body.id, "create"); + if (commitConflict) return jsonResponse({ error: commitConflict }, 400); saveCodexAccountCredential(body.id, { accessToken: body.accessToken, refreshToken: body.refreshToken, @@ -556,9 +682,10 @@ export async function handleCodexAuthAPI( }); markCodexAccountValidated(body.id, warmup.validatedAt); clearAccountNeedsReauth(body.id); + const accounts = latestConfig.codexAccounts ?? []; accounts.push(withCodexAccountLogLabel({ id: body.id, email: body.email, plan: body.plan, isMain: false }, accounts)); - runtimeConfig.codexAccounts = accounts; - saveRuntimeConfig(config, runtimeConfig); + latestConfig.codexAccounts = accounts; + saveRuntimeConfig(config, latestConfig); return jsonResponse({ ok: true }); } @@ -566,6 +693,11 @@ export async function handleCodexAuthAPI( const id = url.searchParams.get("id"); if (!id) return jsonResponse({ error: "Missing id" }, 400); const runtimeConfig = getRuntimeConfig(config); + const isLegacyPoolAccount = CODEX_ACCOUNT_ID_RE.test(id) + && (runtimeConfig.codexAccounts ?? []).some(account => !account.isMain && account.id === id); + if (!isValidCodexAccountId(id) && !isLegacyPoolAccount) { + return jsonResponse({ error: "Invalid account id format" }, 400); + } deleteCodexAccount(runtimeConfig, id); saveRuntimeConfig(config, runtimeConfig); return jsonResponse({ ok: true }); @@ -575,8 +707,8 @@ export async function handleCodexAuthAPI( const body = await req.json().catch(() => ({})) as { id?: unknown; alias?: unknown }; const id = typeof body.id === "string" ? body.id.trim() : ""; const alias = typeof body.alias === "string" ? body.alias.trim() : ""; - if (!id || !ACCOUNT_ID_RE.test(id)) return jsonResponse({ error: "Invalid account id format" }, 400); if (id === MAIN_CODEX_ACCOUNT_ID) return jsonResponse({ error: "Main Codex account alias is not configurable" }, 400); + if (!isValidCodexAccountId(id)) return jsonResponse({ error: "Invalid account id format" }, 400); if (typeof body.alias !== "string" || alias.length > 80 || /[\x00-\x1f\x7f]/.test(alias)) { return jsonResponse({ error: "Alias must be a string of at most 80 printable characters" }, 400); } @@ -589,6 +721,59 @@ export async function handleCodexAuthAPI( return jsonResponse({ ok: true, id, alias: alias || null }); } + if (url.pathname === "/api/codex-auth/accounts/pause" && req.method === "PUT") { + const body = await req.json().catch(() => ({})) as { id?: unknown; paused?: unknown }; + const id = typeof body.id === "string" ? body.id.trim() : ""; + if (id !== MAIN_CODEX_ACCOUNT_ID && !isValidCodexAccountId(id)) { + return jsonResponse({ error: "Invalid account id format" }, 400); + } + if (typeof body.paused !== "boolean") return jsonResponse({ error: "paused must be a boolean" }, 400); + + const runtimeConfig = getRuntimeConfig(config); + const exists = id === MAIN_CODEX_ACCOUNT_ID + || (runtimeConfig.codexAccounts ?? []).some(account => isSelectableCodexPoolAccount(account) && account.id === id); + if (!exists) return jsonResponse({ error: "Account not found" }, 404); + + setCodexAccountPaused(runtimeConfig, id, body.paused); + if (body.paused) { + clearThreadAccountMapForAccount(id); + selectFallbackAfterPause(runtimeConfig, id); + } + saveRuntimeConfig(config, runtimeConfig); + return jsonResponse({ + ok: true, + id, + paused: body.paused, + activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null, + appliesImmediately: true, + }); + } + + if (url.pathname === "/api/codex-auth/accounts/pause-exhausted" && req.method === "PUT") { + const runtimeConfig = getRuntimeConfig(config); + const result = await pauseExhaustedCodexAccounts(runtimeConfig); + const { pausedAccountIds, checkedAccountCount, failedAccountCount } = result; + if (checkedAccountCount === 0 && failedAccountCount > 0) { + return jsonResponse({ + ok: false, + error: "Failed to refresh any Codex account quota", + checkedAccountCount, + failedAccountCount, + }, 502); + } + if (pausedAccountIds.length > 0) saveRuntimeConfig(config, runtimeConfig); + return jsonResponse({ + ok: true, + pausedAccountIds, + pausedCount: pausedAccountIds.length, + checkedAccountCount, + failedAccountCount, + complete: failedAccountCount === 0, + activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null, + appliesImmediately: true, + }); + } + // Manual escape from a quota cooldown. Injected Codex routing makes this proxy the only // model path for Codex Desktop, so a cooldown that outlives the real upstream limit // otherwise leaves editing config.toml as the user's only recovery. @@ -600,7 +785,9 @@ export async function handleCodexAuthAPI( if (url.pathname === "/api/codex-auth/accounts/clear-cooldown" && req.method === "POST") { const body = await req.json().catch(() => ({})) as { id?: unknown }; const id = typeof body.id === "string" ? body.id.trim() : ""; - if (!id || !ACCOUNT_ID_RE.test(id)) return jsonResponse({ error: "Invalid account id format" }, 400); + if (id !== MAIN_CODEX_ACCOUNT_ID && !isValidCodexAccountId(id)) { + return jsonResponse({ error: "Invalid account id format" }, 400); + } return jsonResponse({ ok: true, id, cleared: clearCodexAccountCooldown(id) }); } @@ -608,12 +795,21 @@ export async function handleCodexAuthAPI( let body: { accountId: string | null }; try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); } const runtimeConfig = getRuntimeConfig(config); + const targetAccountId = body.accountId ?? MAIN_CODEX_ACCOUNT_ID; + if (body.accountId === MAIN_CODEX_ACCOUNT_ID && hasLegacyMainCodexPoolAccount(runtimeConfig.codexAccounts)) { + return jsonResponse({ error: "Remove the legacy __main__ pool row before selecting the Desktop account" }, 409); + } + if (isCodexAccountPaused(runtimeConfig, targetAccountId)) { + return jsonResponse({ error: "Account is paused" }, 409); + } if (body.accountId != null && body.accountId !== MAIN_CODEX_ACCOUNT_ID) { - const exists = (runtimeConfig.codexAccounts ?? []).some(a => a.id === body.accountId); + if (!isValidCodexAccountId(body.accountId)) return jsonResponse({ error: "Invalid account id format" }, 400); + const exists = (runtimeConfig.codexAccounts ?? []) + .some(account => isSelectableCodexPoolAccount(account) && account.id === body.accountId); if (!exists) return jsonResponse({ error: "Account not found" }, 400); } runtimeConfig.activeCodexAccountId = body.accountId ?? undefined; - resetCodexRoutingForManualSelection(body.accountId ?? MAIN_CODEX_ACCOUNT_ID); + resetCodexRoutingForManualSelection(targetAccountId); saveRuntimeConfig(config, runtimeConfig); return jsonResponse({ ok: true, activeCodexAccountId: body.accountId, appliesImmediately: true }); } @@ -782,15 +978,15 @@ export async function handleCodexAuthAPI( const body = (await req.json().catch(() => ({}))) as { id?: string; reauth?: boolean }; const requestedAccountId = body.id?.trim(); const reauth = body.reauth === true; - if (requestedAccountId && !ACCOUNT_ID_RE.test(requestedAccountId)) { + if (requestedAccountId && !isValidCodexAccountId(requestedAccountId)) { return jsonResponse({ error: "Invalid account id format" }, 400); } const accountId = requestedAccountId || `chatgpt-${Date.now()}`; const runtimeConfig = getRuntimeConfig(config); - const exists = (runtimeConfig.codexAccounts ?? []).some(a => a.id === accountId) || Boolean(getCodexAccountCredential(accountId)); - if (exists && !reauth) { - return jsonResponse({ error: `Account id already exists: ${accountId}` }, 400); - } + const preflightConflict = !reauth + ? codexAccountPersistenceConflict(runtimeConfig, accountId, "create") + : undefined; + if (preflightConflict) return jsonResponse({ error: preflightConflict }, 400); if (reauth) { if (!requestedAccountId) return jsonResponse({ error: "id required for reauth" }, 400); if (!configuredPoolAccount(runtimeConfig, accountId)) { @@ -908,6 +1104,24 @@ export async function handleCodexAuthAPI( break; } + const latestConfig = getRuntimeConfig(config); + const accounts = latestConfig.codexAccounts ?? []; + const existingIdx = accounts.findIndex(account => account.id === accountId); + const commitConflict = codexAccountPersistenceConflict( + latestConfig, + accountId, + reauth ? "reauth" : "create", + ); + if (commitConflict) { + codexAuthLoginState.set(flowId, { + status: "error", + error: commitConflict, + doneAt: Date.now(), + }); + completed = true; + break; + } + saveCodexAccountCredential(accountId, { accessToken: cred.access, refreshToken: cred.refresh, @@ -920,15 +1134,12 @@ export async function handleCodexAuthAPI( setAccountQuotaFromParsed(accountId, quota); } - const latestConfig = getRuntimeConfig(config); - const accounts = latestConfig.codexAccounts ?? []; - const existingIdx = accounts.findIndex(a => a.id === accountId); if (existingIdx >= 0) { // Keep the pool id stable; refresh display metadata after a successful login/reauth. accounts[existingIdx] = withCodexAccountLogLabel({ ...accounts[existingIdx], email, - plan, + plan: plan ?? accounts[existingIdx].plan, isMain: false, }, accounts); latestConfig.codexAccounts = accounts; diff --git a/src/codex/auth-collision.ts b/src/codex/auth-collision.ts index e737dc0d9..a7d10e7c9 100644 --- a/src/codex/auth-collision.ts +++ b/src/codex/auth-collision.ts @@ -4,6 +4,7 @@ import { getCodexAccountCredential } from "./account-store"; import { loadConfig } from "../config"; import { resolveCodexHomeDir } from "./home"; import { extractAccountId } from "../oauth/chatgpt"; +import { isSelectableCodexPoolAccount } from "./account-id"; export interface CodexTokens { access_token: string; @@ -94,7 +95,7 @@ export function checkAccountIdCollision( const candidateWorkspace = isWorkspacePlan(plan); for (const account of loadConfig().codexAccounts ?? []) { if (excludeAccountId && account.id === excludeAccountId) continue; - if (account.isMain) continue; + if (!isSelectableCodexPoolAccount(account)) continue; if (isWorkspacePlan(account.plan) !== candidateWorkspace) continue; const cred = getCodexAccountCredential(account.id); const poolEmail = normalizedEmail(account.email); diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index d15c8c4b1..aacea92ee 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -9,13 +9,16 @@ import { isCodexAccountUsable } from "./account-usability"; import { reconcileMainCodexAccountRuntimeState } from "./account-lifecycle"; import { MAIN_CODEX_ACCOUNT_ID, getMainAccountToken } from "./main-account"; import { - getCodexAccountHealthSnapshot, + codexQuotaScopeForModel, + getCodexQuotaHealthSnapshot, releaseCodexQuotaProbeLease, + releaseCodexQuotaScopeProbeLease, tryAcquireCodexQuotaProbeLease, + tryAcquireCodexQuotaScopeProbeLease, pickAlternateCodexAccount, resolveCodexAccountForThreadDetailed, } from "./routing"; -import type { CodexCooldownSource } from "./routing"; +import type { CodexCooldownSource, CodexQuotaScope } from "./routing"; import { maskAccountId } from "../lib/privacy"; import { formatErrorResponse } from "../bridge"; import { getAccountQuota } from "./quota"; @@ -34,8 +37,12 @@ export type CodexAuthContext = * Set when this request was admitted through an active quota cooldown as * the account's single probe. Must be echoed into the upstream outcome so * only this request can clear the cooldown (#433). - */ + */ probeLeaseId?: string; + /** Native model quota group selected for this request, when known. */ + quotaScope?: CodexQuotaScope; + /** Scope that owns `probeLeaseId`, when it is a scoped recovery probe. */ + probeQuotaScope?: CodexQuotaScope; } | { // Main Codex account participating in rotation: token injected from ~/.codex/auth.json @@ -46,6 +53,8 @@ export type CodexAuthContext = chatgptAccountId: string; /** See `pool.probeLeaseId`. */ probeLeaseId?: string; + quotaScope?: CodexQuotaScope; + probeQuotaScope?: CodexQuotaScope; }; /** Probe lease carried by this context, when it holds one. */ @@ -53,13 +62,20 @@ export function codexProbeLeaseId(ctx: CodexAuthContext | undefined): string | u return ctx?.kind === "pool" || ctx?.kind === "main-pool" ? ctx.probeLeaseId : undefined; } +/** Scope of a lease carried by this context, when it probes a model-specific quota. */ +export function codexProbeQuotaScope(ctx: CodexAuthContext | undefined): CodexQuotaScope | undefined { + return ctx?.kind === "pool" || ctx?.kind === "main-pool" ? ctx.probeQuotaScope : undefined; +} + /** * Hand back a probe lease for a request that will not reach upstream. Safe to * call with a context that holds no lease. */ export function releaseCodexAuthContextProbeLease(ctx: CodexAuthContext | undefined): void { const leaseId = codexProbeLeaseId(ctx); - if (ctx && leaseId) releaseCodexQuotaProbeLease(ctx.accountId!, leaseId); + if (!ctx || ctx.kind === "main" || !leaseId) return; + if (ctx.probeQuotaScope) releaseCodexQuotaScopeProbeLease(ctx.accountId!, ctx.probeQuotaScope, leaseId); + else releaseCodexQuotaProbeLease(ctx.accountId!, leaseId); } export type OcxRuntimeProviderConfig = OcxProviderConfig & { @@ -99,13 +115,20 @@ export class CodexAccountCooldownError extends Error { accountId: string; cooldownUntil: number; cooldownSource?: CodexCooldownSource; + quotaScope?: CodexQuotaScope; - constructor(accountId: string, cooldownUntil: number, cooldownSource?: CodexCooldownSource) { + constructor( + accountId: string, + cooldownUntil: number, + cooldownSource?: CodexCooldownSource, + quotaScope?: CodexQuotaScope, + ) { super("Selected Codex account is cooling down"); this.name = "CodexAccountCooldownError"; this.accountId = accountId; this.cooldownUntil = cooldownUntil; this.cooldownSource = cooldownSource; + this.quotaScope = quotaScope; } } @@ -127,7 +150,12 @@ export function cooldownAccountLabel(accountId: string): string { */ export function cooldownErrorMessage(err: CodexAccountCooldownError): string { const until = new Date(err.cooldownUntil).toISOString(); - return `Selected Codex account (${cooldownAccountLabel(err.accountId)}) is cooling down until ${until}` + const scope = err.quotaScope === "spark" + ? "Spark quota" + : err.quotaScope === "shared" + ? "shared native quota" + : null; + return `Selected Codex account (${cooldownAccountLabel(err.accountId)})${scope ? ` ${scope} is` : " is"} cooling down until ${until}` + ` (source: ${err.cooldownSource ?? "default"}).` + ` Run 'ocx account list openai' to find the id, then` + ` 'ocx account clear-cooldown openai ' to lift it, or switch accounts with 'ocx account use openai '.`; @@ -157,6 +185,8 @@ export function shouldMarkAccountNeedsReauthForCodexAuthFailure(cause: unknown): export interface ResolveCodexAuthContextOptions { excludeAccountId?: string; + /** Final native model selected for this request, used to select its quota group. */ + modelId?: string; } export async function resolveCodexAuthContext( @@ -171,16 +201,17 @@ export async function resolveCodexAuthContext( } reconcileMainCodexAccountRuntimeState(); const threadId = headers.get("x-codex-parent-thread-id"); + const quotaScope = codexQuotaScopeForModel(options.modelId); const resolution = options.excludeAccountId ? (() => { - const accountId = pickAlternateCodexAccount(config, options.excludeAccountId!); + const accountId = pickAlternateCodexAccount(config, options.excludeAccountId!, Date.now(), quotaScope); return accountId ? { status: "selected" as const, accountId } : { status: "none" as const }; })() - : resolveCodexAccountForThreadDetailed(threadId, config); + : resolveCodexAccountForThreadDetailed(threadId, config, Date.now(), quotaScope); if (resolution.status === "expired") throw new CodexThreadAffinityExpiredError(resolution.accountId); - const accountId = resolution.status === "selected" ? resolution.accountId : null; + let accountId = resolution.status === "selected" ? resolution.accountId : null; if (!accountId) throw new CodexPoolAuthenticationError(); // Lazy prime: if the selected account has no quota yet, the pool is likely // unprimed (dashboard never opened, or startup prime was blocked). Kick a @@ -194,15 +225,21 @@ export async function resolveCodexAuthContext( } // Snapshot (not just the deadline) so a refused request can report WHY it is cooled: // a literal Retry-After reads very differently to a user than a reset-derived guess. - const cooldown = getCodexAccountHealthSnapshot(accountId); + const cooldown = getCodexQuotaHealthSnapshot(accountId, quotaScope); const cooldownUntil = cooldown?.cooldownUntil; // A cooled-down account never sends traffic, so upstream recovery can never be // observed and the cooldown outlives the real limit. Admit one probe per // interval; its outcome decides whether the cooldown ends (#433). let probeLeaseId: string | undefined; + let probeQuotaScope: CodexQuotaScope | undefined; if (cooldownUntil) { - probeLeaseId = tryAcquireCodexQuotaProbeLease(accountId) ?? undefined; - if (!probeLeaseId) throw new CodexAccountCooldownError(accountId, cooldownUntil, cooldown?.cooldownSource); + probeQuotaScope = cooldown?.quotaScope; + probeLeaseId = probeQuotaScope + ? tryAcquireCodexQuotaScopeProbeLease(accountId, probeQuotaScope) ?? undefined + : tryAcquireCodexQuotaProbeLease(accountId) ?? undefined; + if (!probeLeaseId) { + throw new CodexAccountCooldownError(accountId, cooldownUntil, cooldown?.cooldownSource, cooldown?.quotaScope); + } } if (accountId === MAIN_CODEX_ACCOUNT_ID) { @@ -210,7 +247,8 @@ export async function resolveCodexAuthContext( const token = getMainAccountToken(); if (!token) { // Nothing will reach upstream, so give the probe back instead of burning it. - if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId); + if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId); + else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId); throw new CodexPoolAuthenticationError(); } return { @@ -218,7 +256,9 @@ export async function resolveCodexAuthContext( accountId, accessToken: token.accessToken, chatgptAccountId: token.chatgptAccountId, + ...(quotaScope ? { quotaScope } : {}), ...(probeLeaseId ? { probeLeaseId } : {}), + ...(probeQuotaScope ? { probeQuotaScope } : {}), }; } @@ -230,10 +270,13 @@ export async function resolveCodexAuthContext( generation: token.generation, accessToken: token.accessToken, chatgptAccountId: token.chatgptAccountId, + ...(quotaScope ? { quotaScope } : {}), ...(probeLeaseId ? { probeLeaseId } : {}), + ...(probeQuotaScope ? { probeQuotaScope } : {}), }; } catch (cause) { - if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId); + if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId); + else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId); if (shouldMarkAccountNeedsReauthForCodexAuthFailure(cause)) { markAccountNeedsReauth(accountId); } @@ -245,9 +288,9 @@ export function assertCodexAuthContextNotCooled(ctx: CodexAuthContext | undefine if (ctx?.kind !== "pool" && ctx?.kind !== "main-pool") return; // A context holding the probe lease was deliberately admitted through the cooldown. if (ctx.probeLeaseId) return; - const cooldown = getCodexAccountHealthSnapshot(ctx.accountId); + const cooldown = getCodexQuotaHealthSnapshot(ctx.accountId, ctx.quotaScope); if (cooldown?.cooldownUntil) { - throw new CodexAccountCooldownError(ctx.accountId, cooldown.cooldownUntil, cooldown.cooldownSource); + throw new CodexAccountCooldownError(ctx.accountId, cooldown.cooldownUntil, cooldown.cooldownSource, cooldown.quotaScope); } } diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 7ad985eef..916babd25 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -71,6 +71,32 @@ export function isProviderModelsApiItems(value: unknown): value is ProviderModel ); } +/** + * Normalize OpenAI-compatible /models payloads for catalog discovery. + * Supports `{ data: [...] }` and top-level arrays (Together AI `#617`). + * Google's `{ models: [...] }` is handled by the connectivity probe only — catalog + * discovery must not treat a stray `models` key on openai-chat responses as valid. + */ +export function providerModelsListFromResponse(json: unknown): unknown { + if (Array.isArray(json)) return json; + if (json !== null && typeof json === "object" && !Array.isArray(json)) { + const data = (json as { data?: unknown }).data; + if (Array.isArray(data)) return data; + } + return undefined; +} + +/** Connectivity-probe shape: also accepts Google `{ models: [...] }`. */ +export function providerModelsListFromProbeResponse(json: unknown): unknown { + if (Array.isArray(json)) return json; + if (json !== null && typeof json === "object" && !Array.isArray(json)) { + const obj = json as { data?: unknown; models?: unknown }; + if (Array.isArray(obj.data)) return obj.data; + if (Array.isArray(obj.models)) return obj.models; + } + return undefined; +} + export function configuredContextWindow(prov: OcxProviderConfig, id: string): number | undefined { const configured = modelRecordValue(prov.modelContextWindows, id) ?? prov.contextWindow; return typeof configured === "number" && configured > 0 ? configured : undefined; @@ -366,9 +392,7 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig, } return models; } - const data = json !== null && typeof json === "object" && !Array.isArray(json) - ? (json as { data?: unknown }).data - : undefined; + const data = providerModelsListFromResponse(json); if (!isProviderModelsApiItems(data)) { const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "invalid_response" }); if (shouldLog) { diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 877c18471..3189c1e73 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -329,9 +329,11 @@ export function mergeCatalogEntriesForSync( multiAgentMode: MultiAgentMode = "default", exactComboSlugs: ReadonlySet = new Set(), hasPhysicalComboProvider = false, + includeNativeOpenAi = true, ): RawEntry[] { const rank = new Map(featured.map((slug, i) => [slug, i] as const)); - const native = catalogModels + const native = includeNativeOpenAi + ? catalogModels .filter(m => typeof m.slug === "string" && !(m.slug as string).includes("/") && m.owned_by !== COMBO_NAMESPACE @@ -367,11 +369,14 @@ export function mergeCatalogEntriesForSync( // for subagent max spawns; wire-clamped to the model's real top rung). if (!isGpt56NativeSlug(slug)) ensureUltraReasoningLevel(preserved); return preserved; - }); + }) + : []; // Backfill any native OpenAI slug that the on-disk catalog is missing (e.g. gpt-5.5), so a // routed provider exposing the same id can never delete the native OpenAI/Codex base row. + // Skip when no enabled canonical openai provider exists (#636) — bare gpt-* would 404. const nativeSlugs = new Set(native.flatMap(m => typeof m.slug === "string" ? [m.slug] : [])); + if (includeNativeOpenAi) { for (const slug of nativeOpenAiSlugs()) { if (nativeSlugs.has(slug)) continue; nativeSlugs.add(slug); @@ -382,6 +387,7 @@ export function mergeCatalogEntriesForSync( : 9; native.push(deriveEntry(template ? JSON.parse(JSON.stringify(template)) : null, slug, "OpenAI native model (Codex OAuth passthrough).", priority)); } + } const freshSlugs = new Set( routedEntries.flatMap(entry => typeof entry.slug === "string" ? [entry.slug] : []), @@ -497,7 +503,16 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ // native AND routed so the advertised flag matches the implemented endpoint (phase 120.4) and a // native template can never leak supports_websockets while the flag is off. const wsEnabled = websocketsEnabled(config); - catalog.models = mergeCatalogEntriesForSync(catalog.models ?? [], goEntries, baseline, featured, wsEnabled, goIds, template, disabledNativeSlugs(config), gatheredProviderNames, multiAgentMode, exactComboSlugs, hasPhysicalComboProvider); + const enabledProviders = Object.entries(config.providers ?? {}) + .filter(([, prov]) => prov.disabled !== true); + const hasCanonicalOpenai = enabledProviders.some(([name, prov]) => + name === "openai" && isCanonicalOpenAiForwardProvider(prov), + ); + // #636: when the user only configured non-OpenAI providers (e.g. kimi), do not advertise + // bare gpt-* rows that hard-404 via NoEnabledOpenAiProviderError. Keep natives when no + // providers are configured yet (fresh install / catalog bootstrap tests). + const includeNativeOpenAi = enabledProviders.length === 0 || hasCanonicalOpenai; + catalog.models = mergeCatalogEntriesForSync(catalog.models ?? [], goEntries, baseline, featured, wsEnabled, goIds, template, disabledNativeSlugs(config), gatheredProviderNames, multiAgentMode, exactComboSlugs, hasPhysicalComboProvider, includeNativeOpenAi); clampCatalogModelsToCodexSupport(catalog.models); atomicWriteFile(catalogPath, JSON.stringify(catalog, null, 2) + "\n"); diff --git a/src/codex/main-account.ts b/src/codex/main-account.ts index 6e98e9a76..495ad3930 100644 --- a/src/codex/main-account.ts +++ b/src/codex/main-account.ts @@ -1,14 +1,8 @@ import { readCodexTokens } from "./auth-collision"; import { decodeJwtPayload } from "../oauth/chatgpt"; +import { MAIN_CODEX_ACCOUNT_ID } from "./account-id"; -/** - * Stable id under which the "main" Codex account (the Codex CLI login stored in - * ~/.codex/auth.json) participates in opencodex's account rotation. The main account is - * NOT imported into the managed credential store (Option A): its token is read-only from - * auth.json, so opencodex never refreshes it — an expired token surfaces as a reauth - * notice (re-login via the Codex CLI) rather than a background refresh. - */ -export const MAIN_CODEX_ACCOUNT_ID = "__main__"; +export { MAIN_CODEX_ACCOUNT_ID } from "./account-id"; /** * Main account plan (e.g. "plus", "go", "free", "team"), populated from the WHAM usage diff --git a/src/codex/quota.ts b/src/codex/quota.ts index f500b3641..5fc147116 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -33,6 +33,21 @@ const MONTHLY_WINDOW_MIN_MINUTES = MONTHLY_WINDOW_MIN_SECONDS / 60; const accountQuota = new Map(); export const CODEX_UNKNOWN_USAGE_SCORE = 100; +export const CODEX_EXHAUSTED_USAGE_PERCENT = 100; + +export function isCodexQuotaExhausted( + quota: Pick | null, + plan?: string | null, +): boolean { + if (!quota) return false; + const normalizedPlan = plan?.trim().toLowerCase(); + const values = normalizedPlan === "go" || normalizedPlan === "free" + ? [quota.monthlyPercent] + : [quota.weeklyPercent, quota.monthlyPercent]; + return values.some(value => typeof value === "number" + && Number.isFinite(value) + && value >= CODEX_EXHAUSTED_USAGE_PERCENT); +} export function normalizeUsagePercent(value: unknown): number | undefined { const numeric = typeof value === "number" diff --git a/src/codex/routing.ts b/src/codex/routing.ts index f127b12de..bbc13f844 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { saveConfigPreservingClaudeCode } from "../config"; import { isCodexAccountGenerationLive, readCodexAccountRecord } from "./account-store"; import { codexAccountLogLabel } from "./account-label"; +import { isCodexAccountPaused } from "./account-pause"; import { isCodexAccountUsable } from "./account-usability"; import { isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state"; import { @@ -16,6 +17,7 @@ import { } from "./pool-rotation"; import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota } from "./quota"; import { MAIN_CODEX_ACCOUNT_ID, getMainAccountPlan } from "./main-account"; +import { isSelectableCodexPoolAccount } from "./account-id"; import type { OcxConfig } from "../types"; type ThreadAffinityEntry = { @@ -33,7 +35,6 @@ export type CodexThreadResolution = | { status: "none" } | { status: "expired"; accountId: string }; -const threadAccountMap = new Map(); /** * Process-local cursor for automatic RR/fill-first (and quota-429 when not * sync-writing) picks. Keeps unrelated `saveConfig` from persisting transient @@ -108,22 +109,76 @@ export const CODEX_THREAD_AFFINITY_MAX_ENTRIES = 2048; export const CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS = 60_000; const upstreamHealth = new Map(); +/** + * Reset-derived 429s can describe a quota owned by one native model family, + * rather than the whole ChatGPT account. Keep those advisory cooldowns apart + * from account-wide Retry-After/default throttles and transient health. + */ +const quotaScopedHealth = new Map>(); export type CodexUpstreamOutcome = number | "connect_error" | "timeout"; export type CodexUpstreamOutcomeClass = "success" | "credential" | "quota" | "transient" | "caller" | "unknown"; export type CodexCooldownSource = "retry-after" | "reset-derived" | "default"; +/** + * Native Codex quota groups known to be independent upstream. Keep the mapping + * deliberately conservative: unlisted models share the normal native group. + * Add a new explicit group here only when its independent upstream quota is + * confirmed, so shared limits never receive cross-model bypasses. + */ +export type CodexQuotaScope = "shared" | "spark"; + +/** + * Requests without a resolved native model retain the historic one-account-per- + * thread behavior. Requests with a known quota scope get an independent + * affinity so a Spark failover cannot displace the same thread's Terra/Luna + * account (and vice versa). + */ +type ThreadAffinityScope = CodexQuotaScope | "legacy"; +const LEGACY_THREAD_AFFINITY_SCOPE = "legacy" as const; +const threadAccountMap = new Map>(); + +const NATIVE_MODEL_QUOTA_SCOPES: Readonly> = { + "gpt-5.3-codex-spark": "spark", +}; + +// A thread can have one legacy binding plus one binding for each known scope. +// This upper-bound guard avoids an exact map scan until it can be over capacity. +const MAX_THREAD_AFFINITY_SCOPES = new Set([ + LEGACY_THREAD_AFFINITY_SCOPE, + "shared", + ...Object.values(NATIVE_MODEL_QUOTA_SCOPES), +]).size; + +export function codexQuotaScopeForModel(modelId: string | undefined): CodexQuotaScope | undefined { + if (!modelId?.trim()) return undefined; + return NATIVE_MODEL_QUOTA_SCOPES[modelId.trim().toLowerCase()] ?? "shared"; +} + +/** Independent quota groups must not mutate the shared active-account cursor. */ +function isIndependentCodexQuotaScope(quotaScope?: CodexQuotaScope): boolean { + return quotaScope !== undefined && quotaScope !== "shared"; +} + +function codexPoolKeyForScope(quotaScope?: CodexQuotaScope): string { + return isIndependentCodexQuotaScope(quotaScope) ? `${POOL_KEY_CODEX}:${quotaScope}` : POOL_KEY_CODEX; +} + export type CodexUpstreamOutcomeMeta = { retryAfter?: string | null; resetAt?: unknown | unknown[]; now?: number; + /** Native model selected for this request; used only for confirmed scoped quotas. */ + modelId?: string; /** When set, clears affinity for this thread immediately on transient failure. */ threadId?: string | null; /** * Probe lease held by this request, when it was admitted through an active * quota cooldown. Only the outcome carrying the current lease may clear the * cooldown (#433). - */ + */ probeLeaseId?: string; + /** Scope of `probeLeaseId` when it was granted against a model-scoped cooldown. */ + probeQuotaScope?: CodexQuotaScope; /** * Already-chosen alternate for same-request 429 retry. When set, promotion * reuses this account instead of calling {@link pickAlternateCodexAccount} @@ -134,7 +189,8 @@ export type CodexUpstreamOutcomeMeta = { function hasConfiguredPoolAccount(config: OcxConfig, accountId: string): boolean { if (accountId === MAIN_CODEX_ACCOUNT_ID) return isCodexAccountUsable(config, accountId); - return (config.codexAccounts ?? []).some(account => !account.isMain && account.id === accountId); + return (config.codexAccounts ?? []) + .some(account => isSelectableCodexPoolAccount(account) && account.id === accountId); } export function clearThreadAccountMap(): void { @@ -142,18 +198,23 @@ export function clearThreadAccountMap(): void { } export function clearThreadAccountMapForAccount(accountId: string): void { - for (const [threadId, entry] of threadAccountMap) { - if (entry.accountId === accountId) threadAccountMap.delete(threadId); + for (const [threadId, affinities] of threadAccountMap) { + for (const [scope, entry] of affinities) { + if (entry.accountId === accountId) affinities.delete(scope); + } + if (affinities.size === 0) threadAccountMap.delete(threadId); } } export function clearCodexUpstreamHealth(): void { upstreamHealth.clear(); + quotaScopedHealth.clear(); runtimeActiveCodexAccountId = undefined; } export function clearCodexUpstreamHealthForAccount(accountId: string): void { upstreamHealth.delete(accountId); + quotaScopedHealth.delete(accountId); } export function getCodexUpstreamHealth( @@ -162,6 +223,26 @@ export function getCodexUpstreamHealth( return upstreamHealth.get(accountId) ?? null; } +function scopedHealthFor(accountId: string, scope: CodexQuotaScope): CodexUpstreamHealth | undefined { + return quotaScopedHealth.get(accountId)?.get(scope); +} + +function setScopedHealth(accountId: string, scope: CodexQuotaScope, health: CodexUpstreamHealth): void { + let scopes = quotaScopedHealth.get(accountId); + if (!scopes) { + scopes = new Map(); + quotaScopedHealth.set(accountId, scopes); + } + scopes.set(scope, health); +} + +function deleteScopedHealth(accountId: string, scope: CodexQuotaScope): void { + const scopes = quotaScopedHealth.get(accountId); + if (!scopes) return; + scopes.delete(scope); + if (scopes.size === 0) quotaScopedHealth.delete(accountId); +} + export function computeCodexUsageScore(quota: { weeklyPercent?: number; monthlyPercent?: number; @@ -276,7 +357,10 @@ export function tryAcquireCodexQuotaProbeLease(accountId: string, now = Date.now /** Side-effect-free check mirroring {@link tryAcquireCodexQuotaProbeLease} eligibility. */ export function canAcquireCodexQuotaProbeLease(accountId: string, now = Date.now()): boolean { - const health = upstreamHealth.get(accountId); + return canAcquireQuotaProbeLease(upstreamHealth.get(accountId), now); +} + +function canAcquireQuotaProbeLease(health: CodexUpstreamHealth | undefined, now: number): boolean { if (!health) return false; const cooldownUntil = health.cooldownUntil; if (typeof cooldownUntil !== "number" || !Number.isFinite(cooldownUntil) || cooldownUntil <= now) return false; @@ -286,6 +370,24 @@ export function canAcquireCodexQuotaProbeLease(accountId: string, now = Date.now return now - origin >= CODEX_QUOTA_PROBE_INTERVAL_MS; } +/** Acquire the recovery probe for one confirmed model-specific quota group. */ +export function tryAcquireCodexQuotaScopeProbeLease( + accountId: string, + scope: CodexQuotaScope, + now = Date.now(), +): string | null { + const health = scopedHealthFor(accountId, scope); + if (!canAcquireQuotaProbeLease(health, now)) return null; + const probeLeaseId = randomUUID(); + setScopedHealth(accountId, scope, { + ...health!, + probeLeaseId, + probeLeaseGeneration: health!.cooldownGeneration ?? 0, + lastProbeAt: now, + }); + return probeLeaseId; +} + /** * Hand a probe lease back without recording an upstream outcome. Used by paths * that take a lease and then fail before any request reaches upstream. @@ -296,6 +398,18 @@ export function releaseCodexQuotaProbeLease(accountId: string, leaseId: string, upstreamHealth.set(accountId, withProbeLeaseReleased(health, now)); } +/** Release a model-specific quota probe when the request never reaches upstream. */ +export function releaseCodexQuotaScopeProbeLease( + accountId: string, + scope: CodexQuotaScope, + leaseId: string, + now = Date.now(), +): void { + const health = scopedHealthFor(accountId, scope); + if (!health || health.probeLeaseId !== leaseId) return; + setScopedHealth(accountId, scope, withProbeLeaseReleased(health, now)); +} + /** * True when this outcome belongs to the account's in-flight probe. The * undefined-id guard matters: without it an outcome carrying no lease would match @@ -341,6 +455,11 @@ export function resetCodexRoutingForManualSelection(accountId: string): void { // under round-robin (affinity-cleared threads / null threadId). Fill-first already follows // config.activeCodexAccountId, which the caller persists before invoking this. seedPoolRotationAccount(POOL_KEY_CODEX, accountId); + for (const scope of new Set(Object.values(NATIVE_MODEL_QUOTA_SCOPES))) { + if (isIndependentCodexQuotaScope(scope)) { + seedPoolRotationAccount(codexPoolKeyForScope(scope), accountId); + } + } const current = upstreamHealth.get(accountId); if (!current) return; const preserved = preservedCooldownFields(current); @@ -367,6 +486,33 @@ export function getCodexAccountHealthSnapshot(accountId: string, now = Date.now( }; } +/** + * Read the cooldown relevant to a routed native model. Account-wide cooldowns + * (Retry-After/default) always win; reset-derived scoped state applies only to + * its confirmed quota group. + */ +export function getCodexQuotaHealthSnapshot( + accountId: string, + quotaScope: CodexQuotaScope | undefined, + now = Date.now(), +): { + cooldownUntil?: number; + cooldownSource?: CodexCooldownSource; + quotaScope?: CodexQuotaScope; +} | null { + const account = getCodexAccountHealthSnapshot(accountId, now); + if (account) return account; + if (!quotaScope) return null; + const scoped = scopedHealthFor(accountId, quotaScope); + const cooldownUntil = scoped?.cooldownUntil; + if (typeof cooldownUntil !== "number" || !Number.isFinite(cooldownUntil) || cooldownUntil <= now) return null; + return { + cooldownUntil, + ...(scoped?.cooldownSource ? { cooldownSource: scoped.cooldownSource } : {}), + quotaScope, + }; +} + export function isCodexAccountInCooldown(accountId: string, now = Date.now()): boolean { return getCodexAccountCooldownUntil(accountId, now) !== null; } @@ -390,24 +536,41 @@ export function isCodexAccountInCooldown(accountId: string, now = Date.now()): b * Returns false when the account carried no live cooldown (already expired or never set). */ export function clearCodexAccountCooldown(accountId: string, now = Date.now()): boolean { - const health = upstreamHealth.get(accountId); - if (!health) return false; - const cooldownUntil = health.cooldownUntil; - if (typeof cooldownUntil !== "number" || !Number.isFinite(cooldownUntil) || cooldownUntil <= now) return false; - const { - cooldownUntil: _until, - cooldownSince: _since, - cooldownSource: _source, - probeLeaseId: _leaseId, - probeLeaseGeneration: _leaseGeneration, - ...rest - } = health; - upstreamHealth.set(accountId, { - ...rest, - cooldownGeneration: (health.cooldownGeneration ?? 0) + 1, - lastProbeAt: now, - }); - return true; + const clear = (health: CodexUpstreamHealth): CodexUpstreamHealth | null => { + const cooldownUntil = health.cooldownUntil; + if (typeof cooldownUntil !== "number" || !Number.isFinite(cooldownUntil) || cooldownUntil <= now) return null; + const { + cooldownUntil: _until, + cooldownSince: _since, + cooldownSource: _source, + probeLeaseId: _leaseId, + probeLeaseGeneration: _leaseGeneration, + ...rest + } = health; + return { + ...rest, + cooldownGeneration: (health.cooldownGeneration ?? 0) + 1, + lastProbeAt: now, + }; + }; + + let cleared = false; + const accountHealth = upstreamHealth.get(accountId); + if (accountHealth) { + const next = clear(accountHealth); + if (next) { + upstreamHealth.set(accountId, next); + cleared = true; + } + } + for (const [scope, health] of quotaScopedHealth.get(accountId) ?? []) { + const next = clear(health); + if (next) { + setScopedHealth(accountId, scope, next); + cleared = true; + } + } + return cleared; } export function getCodexAccountSoftAvoidUntil(accountId: string, now = Date.now()): number | null { @@ -421,12 +584,49 @@ export function isCodexAccountSoftAvoided(accountId: string, now = Date.now()): return getCodexAccountSoftAvoidUntil(accountId, now) !== null; } -function isCodexAccountSelectable(config: OcxConfig, accountId: string, now: number): boolean { - return !isCodexAccountInCooldown(accountId, now) +function isCodexAccountSelectable( + config: OcxConfig, + accountId: string, + now: number, + quotaScope?: CodexQuotaScope, +): boolean { + return !isCodexAccountPaused(config, accountId) + && getCodexQuotaHealthSnapshot(accountId, quotaScope, now) === null && !isCodexAccountSoftAvoided(accountId, now) && isCodexAccountUsable(config, accountId); } +function threadAffinityScope(quotaScope?: CodexQuotaScope): ThreadAffinityScope { + return quotaScope ?? LEGACY_THREAD_AFFINITY_SCOPE; +} + +function getThreadAffinity(threadId: string, quotaScope?: CodexQuotaScope): ThreadAffinityEntry | undefined { + return threadAccountMap.get(threadId)?.get(threadAffinityScope(quotaScope)); +} + +function deleteThreadAffinity(threadId: string, quotaScope?: CodexQuotaScope): void { + const affinities = threadAccountMap.get(threadId); + if (!affinities) return; + affinities.delete(threadAffinityScope(quotaScope)); + if (affinities.size === 0) threadAccountMap.delete(threadId); +} + +/** Remove only the matching failed account's affinities for one thread. */ +function deleteThreadAffinitiesForAccount(threadId: string, accountId: string): void { + const affinities = threadAccountMap.get(threadId); + if (!affinities) return; + for (const [scope, entry] of affinities) { + if (entry.accountId === accountId) affinities.delete(scope); + } + if (affinities.size === 0) threadAccountMap.delete(threadId); +} + +function threadAffinityEntryCount(): number { + let count = 0; + for (const affinities of threadAccountMap.values()) count += affinities.size; + return count; +} + function isThreadAffinityExpired(entry: ThreadAffinityEntry, now: number): boolean { return now - entry.lastUsedAt > CODEX_THREAD_AFFINITY_IDLE_TTL_MS; } @@ -437,45 +637,69 @@ function isThreadAffinityGenerationLive(entry: ThreadAffinityEntry): boolean { } function pruneExpiredThreadAffinities(now: number): void { - for (const [threadId, entry] of threadAccountMap) { - if (isThreadAffinityExpired(entry, now)) threadAccountMap.delete(threadId); + for (const [threadId, affinities] of threadAccountMap) { + for (const [scope, entry] of affinities) { + if (isThreadAffinityExpired(entry, now)) affinities.delete(scope); + } + if (affinities.size === 0) threadAccountMap.delete(threadId); } } function pruneLruThreadAffinities(): void { - while (threadAccountMap.size > CODEX_THREAD_AFFINITY_MAX_ENTRIES) { + if (threadAccountMap.size * MAX_THREAD_AFFINITY_SCOPES <= CODEX_THREAD_AFFINITY_MAX_ENTRIES) return; + while (threadAffinityEntryCount() > CODEX_THREAD_AFFINITY_MAX_ENTRIES) { let oldestThreadId: string | null = null; + let oldestScope: ThreadAffinityScope | null = null; let oldestLastUsedAt = Number.POSITIVE_INFINITY; - for (const [threadId, entry] of threadAccountMap) { - if (entry.lastUsedAt < oldestLastUsedAt) { - oldestThreadId = threadId; - oldestLastUsedAt = entry.lastUsedAt; + for (const [threadId, affinities] of threadAccountMap) { + for (const [scope, entry] of affinities) { + if (entry.lastUsedAt < oldestLastUsedAt) { + oldestThreadId = threadId; + oldestScope = scope; + oldestLastUsedAt = entry.lastUsedAt; + } } } - if (!oldestThreadId) return; - threadAccountMap.delete(oldestThreadId); + if (!oldestThreadId || !oldestScope) return; + deleteThreadAffinity(oldestThreadId, oldestScope === LEGACY_THREAD_AFFINITY_SCOPE ? undefined : oldestScope); } } -function bindThreadAffinity(threadId: string, accountId: string, now: number): void { +function bindThreadAffinity( + threadId: string, + accountId: string, + now: number, + quotaScope?: CodexQuotaScope, +): void { const record = accountId === MAIN_CODEX_ACCOUNT_ID ? undefined : readCodexAccountRecord(accountId); if (accountId !== MAIN_CODEX_ACCOUNT_ID && (!record?.credential || record.deletedAt != null)) return; pruneExpiredThreadAffinities(now); - const previous = threadAccountMap.get(threadId); - threadAccountMap.set(threadId, { + const scope = threadAffinityScope(quotaScope); + const affinities = threadAccountMap.get(threadId) ?? new Map(); + const previous = affinities.get(scope); + affinities.set(scope, { accountId, generation: accountId === MAIN_CODEX_ACCOUNT_ID ? 0 : record!.generation, createdAt: previous?.createdAt ?? now, lastUsedAt: now, lastReevalAt: now, }); + threadAccountMap.set(threadId, affinities); pruneLruThreadAffinities(); } -function getEligiblePoolAccounts(config: OcxConfig, excludeId?: string, now = Date.now()): string[] { +function getEligiblePoolAccounts( + config: OcxConfig, + excludeId?: string, + now = Date.now(), + quotaScope?: CodexQuotaScope, +): string[] { const ids = (config.codexAccounts ?? []) - .filter(account => !account.isMain && account.id !== excludeId && !isAccountNeedsReauth(account.id)) - .filter(account => !isCodexAccountInCooldown(account.id, now)) + .filter(account => isSelectableCodexPoolAccount(account) + && account.id !== excludeId + && !isCodexAccountPaused(config, account.id) + && !isAccountNeedsReauth(account.id)) + .filter(account => getCodexQuotaHealthSnapshot(account.id, quotaScope, now) === null) .filter(account => !isCodexAccountSoftAvoided(account.id, now)) .filter(account => isCodexAccountUsable(config, account.id)) .map(account => account.id); @@ -483,8 +707,9 @@ function getEligiblePoolAccounts(config: OcxConfig, excludeId?: string, now = Da // first-class rotation candidate when its read-only token is usable (Option A). if ( excludeId !== MAIN_CODEX_ACCOUNT_ID + && !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID) && !isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID) - && !isCodexAccountInCooldown(MAIN_CODEX_ACCOUNT_ID, now) + && getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, quotaScope, now) === null && !isCodexAccountSoftAvoided(MAIN_CODEX_ACCOUNT_ID, now) && isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID) ) { @@ -493,8 +718,12 @@ function getEligiblePoolAccounts(config: OcxConfig, excludeId?: string, now = Da return ids; } -function listEligibleCodexAccountIds(config: OcxConfig, now: number): string[] { - return getEligiblePoolAccounts(config, undefined, now); +function listEligibleCodexAccountIds( + config: OcxConfig, + now: number, + quotaScope?: CodexQuotaScope, +): string[] { + return getEligiblePoolAccounts(config, undefined, now, quotaScope); } function stickyLimitForConfig(config: OcxConfig): number { @@ -514,8 +743,12 @@ function isActiveUnderFillFirstThreshold(config: OcxConfig, accountId: string): * Fill-first: keep selectable active under threshold; otherwise advance to the next * eligible id in stable sorted order after the current active (wrapping). */ -function pickFillFirstCodexAccount(config: OcxConfig, now: number): string | null { - const eligible = listEligibleCodexAccountIds(config, now); +function pickFillFirstCodexAccount( + config: OcxConfig, + now: number, + quotaScope?: CodexQuotaScope, +): string | null { + const eligible = listEligibleCodexAccountIds(config, now, quotaScope); if (eligible.length === 0) return null; const active = getEffectiveActiveCodexAccountId(config); @@ -589,31 +822,33 @@ function pickUnboundStrategyAccount( threadId: string | null, now: number, commit: boolean, + quotaScope?: CodexQuotaScope, ): string | null { const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy); if (strategy === "quota") return null; + const poolKey = codexPoolKeyForScope(quotaScope); let picked: string | null = null; if (strategy === "round-robin") { - const eligible = listEligibleCodexAccountIds(config, now); + const eligible = listEligibleCodexAccountIds(config, now, quotaScope); const limit = stickyLimitForConfig(config); if (!commit) { - return peekRoundRobinAccount(POOL_KEY_CODEX, eligible, limit); + return peekRoundRobinAccount(poolKey, eligible, limit); } - picked = pickRoundRobinAccount(POOL_KEY_CODEX, eligible, limit); + picked = pickRoundRobinAccount(poolKey, eligible, limit); if (!picked) return null; - rememberActiveCodexAccount(config, picked); - if (threadId) bindThreadAffinity(threadId, picked, now); - notePoolRotationSuccess(POOL_KEY_CODEX, picked, limit); + if (!isIndependentCodexQuotaScope(quotaScope)) rememberActiveCodexAccount(config, picked); + if (threadId) bindThreadAffinity(threadId, picked, now, quotaScope); + notePoolRotationSuccess(poolKey, picked, limit); return picked; } if (strategy === "fill-first") { - picked = pickFillFirstCodexAccount(config, now); + picked = pickFillFirstCodexAccount(config, now, quotaScope); if (!picked) return null; if (commit) { - rememberActiveCodexAccount(config, picked); - if (threadId) bindThreadAffinity(threadId, picked, now); + if (!isIndependentCodexQuotaScope(quotaScope)) rememberActiveCodexAccount(config, picked); + if (threadId) bindThreadAffinity(threadId, picked, now, quotaScope); } return picked; } @@ -623,13 +858,20 @@ function pickUnboundStrategyAccount( export function getPoolAccountPlan(config: OcxConfig, accountId: string): string | undefined { if (accountId === MAIN_CODEX_ACCOUNT_ID) return getMainAccountPlan(); - return (config.codexAccounts ?? []).find(account => !account.isMain && account.id === accountId)?.plan; + return (config.codexAccounts ?? []) + .find(account => isSelectableCodexPoolAccount(account) && account.id === accountId)?.plan; } -function pickLowerUsageAccount(config: OcxConfig, active: string, activeUsage: number, now: number): string { +function pickLowerUsageAccount( + config: OcxConfig, + active: string, + activeUsage: number, + now: number, + quotaScope?: CodexQuotaScope, +): string { let best = active; let bestUsage = activeUsage; - for (const id of getEligiblePoolAccounts(config, active, now)) { + for (const id of getEligiblePoolAccounts(config, active, now, quotaScope)) { const usage = computeCodexUsageScore(getAccountQuota(id), getPoolAccountPlan(config, id)); if (usage < bestUsage) { best = id; @@ -639,10 +881,15 @@ function pickLowerUsageAccount(config: OcxConfig, active: string, activeUsage: n return best; } -export function pickLowestUsageCodexAccount(config: OcxConfig, excludeId?: string, now = Date.now()): string | null { +export function pickLowestUsageCodexAccount( + config: OcxConfig, + excludeId?: string, + now = Date.now(), + quotaScope?: CodexQuotaScope, +): string | null { let best: string | null = null; let bestUsage = Number.POSITIVE_INFINITY; - for (const id of getEligiblePoolAccounts(config, excludeId, now)) { + for (const id of getEligiblePoolAccounts(config, excludeId, now, quotaScope)) { const usage = computeCodexUsageScore(getAccountQuota(id), getPoolAccountPlan(config, id)); if (usage < bestUsage) { best = id; @@ -661,17 +908,18 @@ export function pickAlternateCodexAccount( config: OcxConfig, excludeId: string, now = Date.now(), + quotaScope?: CodexQuotaScope, ): string | null { const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy); if (strategy === "round-robin") { - const eligible = listEligibleCodexAccountIds(config, now).filter(id => id !== excludeId); - return pickRoundRobinAccount(POOL_KEY_CODEX, eligible, stickyLimitForConfig(config)); + const eligible = listEligibleCodexAccountIds(config, now, quotaScope).filter(id => id !== excludeId); + return pickRoundRobinAccount(codexPoolKeyForScope(quotaScope), eligible, stickyLimitForConfig(config)); } if (strategy === "fill-first") { - const eligible = listEligibleCodexAccountIds(config, now).filter(id => id !== excludeId); + const eligible = listEligibleCodexAccountIds(config, now, quotaScope).filter(id => id !== excludeId); return pickNextFillFirstCodexAccount(config, excludeId, eligible, now); } - return pickLowestUsageCodexAccount(config, excludeId, now); + return pickLowestUsageCodexAccount(config, excludeId, now, quotaScope); } /** Effective active: automatic runtime cursor, else operator/persisted selection. */ @@ -704,11 +952,39 @@ function promoteActiveCodexAccount(config: OcxConfig, accountId: string): void { rememberActiveCodexAccount(config, accountId); } +/** + * Reconcile the effective active account after an administrative exclusion such as pause. + * The operator's persisted selection is cleared when it names the excluded account; quota + * keeps its historical persisted promotion, while rotating strategies retain the replacement + * only in the process-local cursor. + */ +export function reconcileCodexActiveAfterExclusion( + config: OcxConfig, + excludedAccountId: string, + now = Date.now(), +): string | null { + const wasEffective = (getEffectiveActiveCodexAccountId(config) ?? MAIN_CODEX_ACCOUNT_ID) === excludedAccountId; + if (config.activeCodexAccountId === excludedAccountId) { + config.activeCodexAccountId = undefined; + } + if (!wasEffective) return getEffectiveActiveCodexAccountId(config) ?? null; + + runtimeActiveCodexAccountId = undefined; + const fallback = pickAlternateCodexAccount(config, excludedAccountId, now); + if (fallback) promoteActiveCodexAccount(config, fallback); + return fallback; +} + function isUnknownUsage(usage: number): boolean { return usage >= CODEX_UNKNOWN_USAGE_SCORE; } -function applyQuotaAutoSwitch(config: OcxConfig, active: string, now: number): string { +function applyQuotaAutoSwitch( + config: OcxConfig, + active: string, + now: number, + quotaScope?: CodexQuotaScope, +): string { const threshold = config.autoSwitchThreshold ?? 80; if (threshold <= 0) return active; const quota = getAccountQuota(active); @@ -717,9 +993,9 @@ function applyQuotaAutoSwitch(config: OcxConfig, active: string, now: number): s // threshold. Wait for quota priming instead of rotating among guesses. if (isUnknownUsage(activeUsage)) return active; if (activeUsage < threshold) return active; - const best = pickLowerUsageAccount(config, active, activeUsage, now); + const best = pickLowerUsageAccount(config, active, activeUsage, now, quotaScope); if (best !== active) { - setActiveCodexAccount(config, best); + if (!isIndependentCodexQuotaScope(quotaScope)) setActiveCodexAccount(config, best); return best; } @@ -748,8 +1024,9 @@ export function resolveCodexAccountForThread( threadId: string | null, config: OcxConfig, now = Date.now(), + quotaScope?: CodexQuotaScope, ): string | null { - const resolution = resolveCodexAccountForThreadDetailed(threadId, config, now); + const resolution = resolveCodexAccountForThreadDetailed(threadId, config, now, quotaScope); return resolution.status === "selected" ? resolution.accountId : null; } @@ -765,13 +1042,14 @@ export function previewCodexAccountForRequest( threadId: string | null, config: OcxConfig, now = Date.now(), + quotaScope?: CodexQuotaScope, ): string | null { - if (threadId && threadAccountMap.has(threadId)) { - const entry = threadAccountMap.get(threadId)!; + const entry = threadId ? getThreadAffinity(threadId, quotaScope) : undefined; + if (threadId && entry) { if ( !isThreadAffinityExpired(entry, now) && isThreadAffinityGenerationLive(entry) - && isCodexAccountSelectable(config, entry.accountId, now) + && isCodexAccountSelectable(config, entry.accountId, now, quotaScope) && !shouldFailover(config, entry.accountId, now) ) { // Quota strategy only: non-quota strategies keep affinity for ongoing threads @@ -785,7 +1063,7 @@ export function previewCodexAccountForRequest( getPoolAccountPlan(config, entry.accountId), ); if (!isUnknownUsage(usage) && usage >= threshold) { - const best = pickLowerUsageAccount(config, entry.accountId, usage, now); + const best = pickLowerUsageAccount(config, entry.accountId, usage, now, quotaScope); if (best !== entry.accountId) return best; } } @@ -795,17 +1073,17 @@ export function previewCodexAccountForRequest( // Stale/unusable affinity is ignored for preview (no map mutation). } - const strategyPick = pickUnboundStrategyAccount(config, threadId, now, false); + const strategyPick = pickUnboundStrategyAccount(config, threadId, now, false, quotaScope); if (strategyPick) return strategyPick; let active = getEffectiveActiveCodexAccountId(config) ?? null; if (!active) { - return pickLowestUsageCodexAccount(config, undefined, now); + return pickLowestUsageCodexAccount(config, undefined, now, quotaScope); } - if (!isCodexAccountSelectable(config, active, now)) { - const fallback = pickLowestUsageCodexAccount(config, active, now); + if (!isCodexAccountSelectable(config, active, now, quotaScope)) { + const fallback = pickLowestUsageCodexAccount(config, active, now, quotaScope); if (fallback) active = fallback; - else if (hasConfiguredPoolAccount(config, active)) return active; + else if (hasConfiguredPoolAccount(config, active) && !isCodexAccountPaused(config, active)) return active; else return null; } @@ -813,17 +1091,18 @@ export function previewCodexAccountForRequest( if (threshold > 0) { const usage = computeCodexUsageScore(getAccountQuota(active), getPoolAccountPlan(config, active)); if (!isUnknownUsage(usage) && usage >= threshold) { - active = pickLowerUsageAccount(config, active, usage, now); + active = pickLowerUsageAccount(config, active, usage, now, quotaScope); } } if (shouldFailover(config, active, now)) { - const best = pickLowestUsageCodexAccount(config, active, now); + const best = pickLowestUsageCodexAccount(config, active, now, quotaScope); if (best) active = best; } if (!isCodexAccountUsable(config, active)) { return hasConfiguredPoolAccount(config, active) ? active : null; } - if (isCodexAccountInCooldown(active, now)) { + if (isCodexAccountPaused(config, active)) return null; + if (getCodexQuotaHealthSnapshot(active, quotaScope, now)) { return hasConfiguredPoolAccount(config, active) ? active : null; } return active; @@ -833,16 +1112,17 @@ export function resolveCodexAccountForThreadDetailed( threadId: string | null, config: OcxConfig, now = Date.now(), + quotaScope?: CodexQuotaScope, ): CodexThreadResolution { - if (threadId && threadAccountMap.has(threadId)) { - const entry = threadAccountMap.get(threadId)!; + const entry = threadId ? getThreadAffinity(threadId, quotaScope) : undefined; + if (threadId && entry) { if (isThreadAffinityExpired(entry, now)) { - threadAccountMap.delete(threadId); + deleteThreadAffinity(threadId, quotaScope); return { status: "expired", accountId: entry.accountId }; } if ( isThreadAffinityGenerationLive(entry) - && isCodexAccountSelectable(config, entry.accountId, now) + && isCodexAccountSelectable(config, entry.accountId, now, quotaScope) // Affined threads must leave a failing account once the streak trips failover // (soft-avoid covers the first-hit case; this catches post-avoid residual streaks). && !shouldFailover(config, entry.accountId, now) @@ -869,10 +1149,10 @@ export function resolveCodexAccountForThreadDetailed( if (overThreshold || now - entry.lastReevalAt >= CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS) { entry.lastReevalAt = now; if (overThreshold) { - const best = pickLowerUsageAccount(config, entry.accountId, usage, now); + const best = pickLowerUsageAccount(config, entry.accountId, usage, now, quotaScope); if (best !== entry.accountId) { - setActiveCodexAccount(config, best); - bindThreadAffinity(threadId, best, now); // rebinds + resets clocks + if (!isIndependentCodexQuotaScope(quotaScope)) setActiveCodexAccount(config, best); + bindThreadAffinity(threadId, best, now, quotaScope); // rebinds + resets clocks return { status: "selected", accountId: best }; } } @@ -880,39 +1160,40 @@ export function resolveCodexAccountForThreadDetailed( } return { status: "selected", accountId: entry.accountId }; } - threadAccountMap.delete(threadId); + deleteThreadAffinity(threadId, quotaScope); } - const strategyPick = pickUnboundStrategyAccount(config, threadId, now, true); + const strategyPick = pickUnboundStrategyAccount(config, threadId, now, true, quotaScope); if (strategyPick) return { status: "selected", accountId: strategyPick }; let active = getEffectiveActiveCodexAccountId(config); if (!active) { - const selected = pickLowestUsageCodexAccount(config, undefined, now); + const selected = pickLowestUsageCodexAccount(config, undefined, now, quotaScope); if (!selected) return { status: "none" }; - setActiveCodexAccount(config, selected); + if (!isIndependentCodexQuotaScope(quotaScope)) setActiveCodexAccount(config, selected); active = selected; } - if (!isCodexAccountSelectable(config, active, now)) { - const fallback = pickLowestUsageCodexAccount(config, active, now); + if (!isCodexAccountSelectable(config, active, now, quotaScope)) { + const fallback = pickLowestUsageCodexAccount(config, active, now, quotaScope); if (fallback) { - setActiveCodexAccount(config, fallback); + if (!isIndependentCodexQuotaScope(quotaScope)) setActiveCodexAccount(config, fallback); active = fallback; - } else if (hasConfiguredPoolAccount(config, active)) { + } else if (hasConfiguredPoolAccount(config, active) && !isCodexAccountPaused(config, active)) { return { status: "selected", accountId: active }; } else { return { status: "none" }; } } - active = applyQuotaAutoSwitch(config, active, now); + active = applyQuotaAutoSwitch(config, active, now, quotaScope); active = applyFailureFailover(config, active, now); if (!isCodexAccountUsable(config, active)) { return hasConfiguredPoolAccount(config, active) ? { status: "selected", accountId: active } : { status: "none" }; } - if (isCodexAccountInCooldown(active, now)) { + if (isCodexAccountPaused(config, active)) return { status: "none" }; + if (getCodexQuotaHealthSnapshot(active, quotaScope, now)) { return hasConfiguredPoolAccount(config, active) ? { status: "selected", accountId: active } : { status: "none" }; } - if (threadId) bindThreadAffinity(threadId, active, now); + if (threadId) bindThreadAffinity(threadId, active, now, quotaScope); return { status: "selected", accountId: active }; } @@ -925,7 +1206,18 @@ export function recordCodexUpstreamOutcome( if (!accountId) return; const now = meta.now ?? Date.now(); const outcomeClass = classifyCodexUpstreamOutcome(outcome); + const quotaScope = codexQuotaScopeForModel(meta.modelId); if (outcomeClass === "success") { + const scopedProbe = meta.probeQuotaScope + ? scopedHealthFor(accountId, meta.probeQuotaScope) + : undefined; + if (scopedProbe && meta.probeQuotaScope) { + if (scopedProbe.cooldownUntil && probeMayClearCooldown(scopedProbe, meta)) { + deleteScopedHealth(accountId, meta.probeQuotaScope); + } else if (ownsProbeLease(scopedProbe, meta)) { + setScopedHealth(accountId, meta.probeQuotaScope, withProbeLeaseReleased(scopedProbe, now)); + } + } const current = upstreamHealth.get(accountId); const cooldownUntil = getCodexAccountCooldownUntil(accountId, now); // A leased probe that is still on its own cooldown generation proves the @@ -961,6 +1253,12 @@ export function recordCodexUpstreamOutcome( // A 4xx does not change account health, but it does conclude an in-flight // probe — otherwise the lease would never be handed back. const current = upstreamHealth.get(accountId); + const scopedProbe = meta.probeQuotaScope + ? scopedHealthFor(accountId, meta.probeQuotaScope) + : undefined; + if (scopedProbe && meta.probeQuotaScope && ownsProbeLease(scopedProbe, meta)) { + setScopedHealth(accountId, meta.probeQuotaScope, withProbeLeaseReleased(scopedProbe, now)); + } if (ownsProbeLease(current, meta)) { upstreamHealth.set(accountId, withProbeLeaseReleased(current!, now)); } @@ -976,14 +1274,65 @@ export function recordCodexUpstreamOutcome( lastFailureStatus, lastFailureAt: now, }); + quotaScopedHealth.delete(accountId); markAccountNeedsReauth(accountId); clearThreadAccountMapForAccount(accountId); return; } if (outcomeClass === "quota") { - const prior = upstreamHealth.get(accountId); const { until, source } = computeQuotaCooldown(meta); + // A reset timestamp is an advisory quota-window announcement. When the + // selected native model belongs to a confirmed independent group, preserve + // it there so a different group (Spark versus the shared native quota) can + // still reach upstream. Explicit Retry-After/default 429s remain account-wide. + if (source === "reset-derived" && quotaScope) { + const prior = scopedHealthFor(accountId, quotaScope); + const cooldownGeneration = (prior?.cooldownGeneration ?? 0) + 1; + const ownsLease = meta.probeQuotaScope === quotaScope && ownsProbeLease(prior, meta); + setScopedHealth(accountId, quotaScope, { + consecutiveFailures: 0, + lastFailureStatus, + lastFailureAt: now, + cooldownUntil: until, + cooldownSince: now, + cooldownSource: source, + cooldownGeneration, + ...(ownsLease + ? { lastProbeAt: now } + : { + ...(prior?.probeLeaseId !== undefined ? { probeLeaseId: prior.probeLeaseId } : {}), + ...(prior?.probeLeaseGeneration !== undefined ? { probeLeaseGeneration: prior.probeLeaseGeneration } : {}), + ...(prior?.lastProbeAt !== undefined ? { lastProbeAt: prior.lastProbeAt } : {}), + }), + }); + // The shared native scope is the existing account-wide native behavior: + // threads must leave it and new requests should prefer an eligible account. + // Spark remains isolated so a same-account Terra/Luna combo fallback can run. + if (quotaScope === "shared") { + clearThreadAccountMapForAccount(accountId); + notePoolRotationFailure(POOL_KEY_CODEX, accountId); + if (getEffectiveActiveCodexAccountId(config) === accountId) { + // Same-request 429 retry already picked via excludeAccountId — reuse it so + // round-robin does not advance the ring a second time. + const reused = meta.promoteAccountId && meta.promoteAccountId !== accountId + ? meta.promoteAccountId + : null; + const fallback = reused ?? pickAlternateCodexAccount(config, accountId, now, quotaScope); + if (fallback) promoteActiveCodexAccount(config, fallback); + } + } + return; + } + + // A scoped probe that received an account-wide throttle is no longer live. + const scopedProbe = meta.probeQuotaScope + ? scopedHealthFor(accountId, meta.probeQuotaScope) + : undefined; + if (scopedProbe && meta.probeQuotaScope && ownsProbeLease(scopedProbe, meta)) { + setScopedHealth(accountId, meta.probeQuotaScope, withProbeLeaseReleased(scopedProbe, now)); + } + const prior = upstreamHealth.get(accountId); // Every cooldown write bumps the generation so a probe issued against the // previous cooldown can no longer clear this one (#433). const cooldownGeneration = (prior?.cooldownGeneration ?? 0) + 1; @@ -1022,6 +1371,12 @@ export function recordCodexUpstreamOutcome( // transient (connect_error / timeout / 5xx) const current = upstreamHealth.get(accountId); + const scopedProbe = meta.probeQuotaScope + ? scopedHealthFor(accountId, meta.probeQuotaScope) + : undefined; + if (scopedProbe && meta.probeQuotaScope && ownsProbeLease(scopedProbe, meta)) { + setScopedHealth(accountId, meta.probeQuotaScope, withProbeLeaseReleased(scopedProbe, now)); + } // A transient failure concludes an owning probe; an unrelated 5xx must not // consume someone else's live lease or drop hard-cooldown bookkeeping (#433). const transientBase = ownsProbeLease(current, meta) ? withProbeLeaseReleased(current!, now) : current; @@ -1055,8 +1410,7 @@ export function recordCodexUpstreamOutcome( // must not delete a newer healthy binding to account B (race: T→A, A fails, // T→B, late A failure must not delete B's mapping). if (failoverReady && meta.threadId) { - const bound = threadAccountMap.get(meta.threadId); - if (bound?.accountId === accountId) threadAccountMap.delete(meta.threadId); + deleteThreadAffinitiesForAccount(meta.threadId, accountId); } // Once the account is past the failover streak, clear every thread still pinned // to it — matching 429 affinity behavior so "continue" cannot stay on a bad peer. @@ -1072,6 +1426,7 @@ export function formatCodexProviderForLog(providerName: string, accountId: strin // same physical account as the "main" passthrough (null accountId). Log both under the base provider // name so usage/tokens aggregate into a single row instead of splitting into `chatgpt` + `chatgpt-main`. if (accountId === MAIN_CODEX_ACCOUNT_ID) return providerName; - const account = (config.codexAccounts ?? []).find(a => !a.isMain && a.id === accountId); + const account = (config.codexAccounts ?? []) + .find(candidate => isSelectableCodexPoolAccount(candidate) && candidate.id === accountId); return account ? `${providerName}-${codexAccountLogLabel(account)}` : providerName; } diff --git a/src/codex/subagent-model-fallback.ts b/src/codex/subagent-model-fallback.ts index ec07d51db..6e936ec22 100644 --- a/src/codex/subagent-model-fallback.ts +++ b/src/codex/subagent-model-fallback.ts @@ -20,6 +20,7 @@ import { isCodexAccountInCooldown, } from "./routing"; import { isCodexAccountUsable } from "./account-usability"; +import { isCodexAccountPaused } from "./account-pause"; import { slugEquals } from "../providers/slug-codec"; import { isThreadSpawnRequest } from "../server/effort-policy"; import { PROVIDER_REGISTRY } from "../providers/registry"; @@ -187,6 +188,7 @@ export function isSubagentModelUnavailable( // route (canonical openai defaults to pool even when codexAccountMode is omitted). const resolvedAccountId = resolvePoolFallbackAccountId(config, accountId); if (!resolvedAccountId) return true; + if (isCodexAccountPaused(config, resolvedAccountId)) return true; if (!isCodexAccountUsable(config, resolvedAccountId)) return true; if ( isCodexAccountInCooldown(resolvedAccountId, now) diff --git a/src/config.ts b/src/config.ts index 1168e0e4e..b7364df49 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,8 +3,15 @@ import { copyFileSync, existsSync, linkSync, mkdirSync, readFileSync, renameSync import { homedir } from "node:os"; import { join, resolve } from "node:path"; import * as z from "zod/v4"; -import { comboConfigIssues } from "./combos/types"; -import { hardenSecretDir, hardenSecretPath } from "./lib/windows-secret-acl"; +import { + CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR, + codexAccountNamespaceForModel, + codexProviderNamespaceKey, + isValidCodexAccountNamespaceTarget, + MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET, +} from "./codex/account-namespace-match"; +import { COMBO_NAMESPACE, comboConfigIssues } from "./combos/types"; +import { hardenSecretDir, hardenSecretPath, hardenSecretPathAsync } from "./lib/windows-secret-acl"; import { providerDestinationConfigError } from "./lib/destination-policy"; import { openRouterRoutingConfigError } from "./providers/openrouter-routing"; import { @@ -15,7 +22,7 @@ import { type OcxConfig, type OcxProviderConfig, } from "./types"; -import { isCanonicalOpenAiForwardProvider } from "./providers/openai-tiers"; +import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "./providers/openai-tiers"; import { parseDesktopProfile } from "./claude/desktop-profile"; import { isCodexReasoningEffort, modelRecordValue } from "./reasoning-effort"; @@ -128,6 +135,90 @@ export function atomicWriteFile(path: string, content: string, io: AtomicWriteIO } } +/** Async atomic-write I/O: harden may await icacls without blocking the event loop (#612). */ +export interface AtomicWriteAsyncIO { + write: (path: string, content: string) => void | Promise; + harden: (path: string) => void | Promise; + rename: (source: string, destination: string) => void | Promise; + truncate: (path: string) => void | Promise; + unlink: (path: string) => void | Promise; +} + +async function renameAtomicFileAsync(source: string, destination: string): Promise { + for (let attempt = 0; ; attempt += 1) { + try { + renameSync(source, destination); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + const transientWindowsError = process.platform === "win32" + && (code === "EBUSY" || code === "EPERM" || code === "EACCES"); + if (!transientWindowsError || attempt >= 2) throw error; + await Bun.sleep(25 * (attempt + 1)); + } + } +} + +/** + * Async atomic write (#612): same temp+harden+rename and residual-temp policy as + * atomicWriteFile, but Windows ACL harden yields the event loop. Timeout memo is keyed + * by the final destination path (not the unique temp, not the parent directory). + */ +export async function atomicWriteFileAsync( + path: string, + content: string, + io?: AtomicWriteAsyncIO, +): Promise { + const effective: AtomicWriteAsyncIO = io ?? { + write: (target, value) => writeFileSync(target, value, { encoding: "utf-8", mode: 0o600 }), + harden: async target => { + try { chmodSync(target, 0o600); } catch { /* platform may ignore chmod */ } + if (process.platform === "win32") { + await hardenSecretPathAsync(target, { required: true, timeoutMemoKey: path }); + } + }, + rename: renameAtomicFileAsync, + truncate: target => truncateSync(target, 0), + unlink: unlinkSync, + }; + const tmp = `${path}.ocx.${process.pid}.${++_atomicSeq}.tmp`; + let hardened = false; + try { + await effective.write(tmp, content); + await effective.harden(tmp); + hardened = true; + await effective.rename(tmp, path); + } catch (cause) { + let scrubbed = false; + try { + await effective.truncate(tmp); + scrubbed = true; + } catch (error) { + if (isMissingPathError(error)) scrubbed = true; + else { + try { await effective.write(tmp, ""); scrubbed = true; } catch { /* removal may still succeed */ } + } + } + let removed = false; + try { + await effective.unlink(tmp); + removed = true; + } catch (error) { + if (isMissingPathError(error)) removed = true; + else { + try { await effective.unlink(tmp); removed = true; } + catch (retryError) { if (isMissingPathError(retryError)) removed = true; } + } + } + if (!removed && !scrubbed) throw new AtomicWriteSecretResidualError(tmp, { cause }); + if (!removed && !hardened) { + try { await effective.harden(tmp); hardened = true; } catch { /* zero-byte residual is reported honestly */ } + } + if (!removed) throw new AtomicWriteResidualTempError(tmp, hardened, { cause }); + throw cause; + } +} + export class OpenAiTierBackupCleanupError extends Error { constructor() { super("OpenAI tier backup temporary cleanup failed"); this.name = "OpenAiTierBackupCleanupError"; } } @@ -525,6 +616,52 @@ export function modelAdapterRecordConfigError( return null; } +const CODEX_ACCOUNT_NAMESPACES_RECORD_ERROR = + "codexAccountNamespaces must be a plain object mapping account selectors to Codex account ids"; +const CODEX_ACCOUNT_NAMESPACE_KEY_ERROR = + "account selectors must use 1-64 letters, numbers, dots, underscores, or hyphens and cannot be reserved JavaScript object keys"; +const CODEX_ACCOUNT_NAMESPACE_TARGET_ERROR = + "account selector targets must be @main or valid Codex pool-account ids"; +const CODEX_ACCOUNT_NAMESPACE_ACCOUNT_ID_COLLISION_ERROR = + "account selectors must not collide with configured Codex pool-account ids or account selector targets"; + +function configuredCodexPoolAccountIds(value: unknown): Set { + const accountIds = new Set(); + if (!Array.isArray(value)) return accountIds; + for (const account of value) { + if (!account || typeof account !== "object" || Array.isArray(account)) continue; + const { id, isMain } = account as { id?: unknown; isMain?: unknown }; + if (typeof id === "string" && isMain !== true) accountIds.add(id); + } + return accountIds; +} + +const codexAccountNamespacesSchema = z.custom>( + (value): value is Record => !!value + && typeof value === "object" + && !Array.isArray(value) + && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null), + { error: CODEX_ACCOUNT_NAMESPACES_RECORD_ERROR }, +).superRefine((accountNamespaces, ctx) => { + // Inspect raw own entries before z.record parses them; Zod omits __proto__ record keys. + for (const [namespace, accountId] of Object.entries(accountNamespaces)) { + if (!isValidProviderName(namespace)) { + ctx.addIssue({ + code: "custom", + path: [namespace], + message: CODEX_ACCOUNT_NAMESPACE_KEY_ERROR, + }); + } + if (!isValidCodexAccountNamespaceTarget(accountId)) { + ctx.addIssue({ + code: "custom", + path: [namespace], + message: CODEX_ACCOUNT_NAMESPACE_TARGET_ERROR, + }); + } + } +}).pipe(z.record(z.string(), z.string())); + const configSchema = z.object({ port: z.number().int().min(0).max(65535).default(10100), providers: z.record(z.string(), providerConfigSchema), @@ -541,6 +678,8 @@ const configSchema = z.object({ injectionEffort: z.string().optional().catch(undefined), syncCodexSubagentDefaults: z.boolean().optional().catch(undefined), codexShimAutoRestore: z.boolean().optional(), + pausedCodexAccountIds: z.array(z.string().regex(/^[a-zA-Z0-9._-]{1,64}$/)).optional(), + codexAccountNamespaces: codexAccountNamespacesSchema.optional(), // Model ids excluded from the Grok Build managed block (dashboard switches). grokExcludedModels: z.array(z.string()).optional(), // Invalid values degrade to undefined ("auto") instead of failing the whole @@ -562,6 +701,36 @@ const configSchema = z.object({ }); } } + + const accountNamespaces = config.codexAccountNamespaces; + if (accountNamespaces) { + const configuredAccountIds = configuredCodexPoolAccountIds(config.codexAccounts); + const configuredProviderNamespaces = new Set([ + COMBO_NAMESPACE, + OPENAI_CODEX_PROVIDER_ID, + ...Object.keys(config.providers), + ].map(codexProviderNamespaceKey)); + const namespaceTargets = new Set( + Object.values(accountNamespaces) + .filter(accountId => accountId !== MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET), + ); + for (const namespace of Object.keys(accountNamespaces)) { + if (configuredProviderNamespaces.has(codexProviderNamespaceKey(namespace))) { + ctx.addIssue({ + code: "custom", + path: ["codexAccountNamespaces", namespace], + message: "account selectors must not collide with configured provider or combo namespaces", + }); + } + if (configuredAccountIds.has(namespace) || namespaceTargets.has(namespace)) { + ctx.addIssue({ + code: "custom", + path: ["codexAccountNamespaces", namespace], + message: CODEX_ACCOUNT_NAMESPACE_ACCOUNT_ID_COLLISION_ERROR, + }); + } + } + } for (const name of Object.keys(config.providers)) { if (!isValidProviderName(name)) { ctx.addIssue({ @@ -731,6 +900,16 @@ const configSchema = z.object({ ctx.addIssue({ code: "custom", path: ["combos"], message: "combos must be an object" }); } else { for (const [id, raw] of Object.entries(combos as Record)) { + const alias = raw && typeof raw === "object" && !Array.isArray(raw) + ? (raw as { alias?: unknown }).alias + : undefined; + if (typeof alias === "string" && codexAccountNamespaceForModel(accountNamespaces, alias.trim())) { + ctx.addIssue({ + code: "custom", + path: ["combos", id, "alias"], + message: CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR, + }); + } // Pass the full map so cross-combo rules (alias uniqueness) apply at load time // too, not just via the management API; each combo is excluded from its own check. for (const issue of comboConfigIssues(id, raw, config.providers, { @@ -1038,6 +1217,16 @@ export function websocketsEnabled(config: Pick): boolea */ const claudeCodeBaseline = new WeakMap(); +/** + * The live config retains the address of the socket Bun actually opened, while + * this map retains the operator's desired address for the next process start. + * Keeping them separate prevents an unrelated live save from restoring a stale + * externally exposed bind after OAuth adopted a newer loopback disk config. + */ +type PersistedServerBinding = Pick; + +const persistedLiveServerBinding = new WeakMap(); + /** * Arm the baseline for a long-lived config. MANDATORY at `startServer`, not lazy on * first save — arming lazily would lose exactly the hand edit made before that first @@ -1074,6 +1263,116 @@ function deepEqual(a: unknown, b: unknown): boolean { return true; } +const MISSING_CONFIG_VALUE = Symbol("missing-config-value"); +type ConfigMergeValue = unknown | typeof MISSING_CONFIG_VALUE; + +function isPlainConfigRecord(value: ConfigMergeValue): value is Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function ownConfigValue(record: Record, key: string): ConfigMergeValue { + return Object.hasOwn(record, key) ? record[key] : MISSING_CONFIG_VALUE; +} + +function cloneConfigValue(value: ConfigMergeValue): ConfigMergeValue { + return value === MISSING_CONFIG_VALUE ? value : structuredClone(value); +} + +function reconcileConfigRecord( + live: Record, + baseline: Record, + persisted: Record, + skippedKeys?: ReadonlySet, +): void { + const keys = new Set([...Object.keys(baseline), ...Object.keys(live), ...Object.keys(persisted)]); + for (const key of keys) { + if (skippedKeys?.has(key)) continue; + const merged = reconcileConfigValue( + ownConfigValue(baseline, key), + ownConfigValue(live, key), + ownConfigValue(persisted, key), + ); + if (merged === MISSING_CONFIG_VALUE) delete live[key]; + else live[key] = merged; + } +} + +function reconcileConfigValue( + baseline: ConfigMergeValue, + live: ConfigMergeValue, + persisted: ConfigMergeValue, +): ConfigMergeValue { + const liveChanged = !deepEqual(live, baseline); + const persistedChanged = !deepEqual(persisted, baseline); + + if (!liveChanged) { + if (live !== MISSING_CONFIG_VALUE && Array.isArray(live) && Array.isArray(persisted)) { + live.splice(0, live.length, ...structuredClone(persisted)); + return live; + } + if (isPlainConfigRecord(live) && isPlainConfigRecord(persisted)) { + reconcileConfigRecord( + live, + isPlainConfigRecord(baseline) ? baseline : {}, + persisted, + ); + return live; + } + return cloneConfigValue(persisted); + } + + if (!persistedChanged) return live; + + if (isPlainConfigRecord(live) + && isPlainConfigRecord(persisted) + && (baseline === MISSING_CONFIG_VALUE || isPlainConfigRecord(baseline))) { + reconcileConfigRecord( + live, + isPlainConfigRecord(baseline) ? baseline : {}, + persisted, + ); + } + // Same-leaf conflicts prefer the pending live management mutation. + return live; +} + +/** + * Reconcile an async OAuth disk commit into the shared live config without erasing + * management mutations that have not saved yet. The baseline is a normalized disk + * snapshot from immediately before login; disjoint object edits merge recursively, + * while same-leaf conflicts prefer live state. + */ +export function reconcileLiveConfigFromDisk(config: OcxConfig, persistedBaseline: OcxConfig): void { + const diagnostics = readConfigDiagnostics(); + if (diagnostics.source === "fallback") { + throw new Error(`OAuth config reconciliation failed: ${diagnostics.error ?? "invalid config file"}`); + } + const persisted = diagnostics.config; + const claudeGuardArmed = claudeCodeBaseline.has(config); + const pendingLiveClaudeMutation = claudeGuardArmed + && !deepEqual(config.claudeCode, claudeCodeBaseline.get(config)); + + persistedLiveServerBinding.set(config, { + port: persisted.port, + ...(persisted.hostname !== undefined ? { hostname: persisted.hostname } : {}), + }); + + reconcileConfigRecord( + config as unknown as Record, + persistedBaseline as unknown as Record, + persisted as unknown as Record, + new Set(["hostname", "port", ...(claudeGuardArmed ? ["claudeCode"] : [])]), + ); + + if (claudeGuardArmed && !pendingLiveClaudeMutation) { + if (persisted.claudeCode === undefined) delete config.claudeCode; + else config.claudeCode = structuredClone(persisted.claudeCode); + claudeCodeBaseline.set(config, structuredClone(config.claudeCode)); + } +} + /** The literal file, with no schema merge or default injection. */ function readRawConfigJson(): Record | undefined { try { @@ -1089,6 +1388,28 @@ function readRawConfigJson(): Record | undefined { } } +/** + * Read only schema-valid binding fields from the literal file. Missing fields mean + * their schema defaults; malformed fields keep the last known persisted value. + */ +function readPersistedServerBinding( + raw: Record, + baseline: PersistedServerBinding, +): PersistedServerBinding { + const port = raw.port === undefined + ? 10100 + : (typeof raw.port === "number" + && Number.isInteger(raw.port) + && raw.port >= 0 + && raw.port <= 65535 + ? raw.port + : baseline.port); + const hostname = raw.hostname === undefined + ? undefined + : (typeof raw.hostname === "string" ? raw.hostname : baseline.hostname); + return { port, ...(hostname !== undefined ? { hostname } : {}) }; +} + /** * The save entry point for every writer holding a LIVE server config. * @@ -1103,8 +1424,11 @@ function readRawConfigJson(): Record | undefined { * guarantee. */ export function saveConfigPreservingClaudeCode(config: OcxConfig): void { + const bindingBaseline = persistedLiveServerBinding.get(config); + const onDisk = claudeCodeBaseline.has(config) || bindingBaseline + ? readRawConfigJson() + : undefined; if (claudeCodeBaseline.has(config)) { - const onDisk = readRawConfigJson(); if (onDisk !== undefined) { const baseline = claudeCodeBaseline.get(config); const diskChanged = !deepEqual(onDisk.claudeCode, baseline); @@ -1114,7 +1438,18 @@ export function saveConfigPreservingClaudeCode(config: OcxConfig): void { } } } - saveConfig(config); + const persistedBinding = bindingBaseline && onDisk + ? readPersistedServerBinding(onDisk, bindingBaseline) + : bindingBaseline; + if (persistedBinding) { + const persistedConfig: OcxConfig = { ...config, port: persistedBinding.port }; + if (persistedBinding.hostname === undefined) delete persistedConfig.hostname; + else persistedConfig.hostname = persistedBinding.hostname; + saveConfig(persistedConfig); + persistedLiveServerBinding.set(config, persistedBinding); + } else { + saveConfig(config); + } if (claudeCodeBaseline.has(config)) { claudeCodeBaseline.set(config, structuredClone(config.claudeCode)); } diff --git a/src/images/artifacts.ts b/src/images/artifacts.ts index d6c887e8a..beceb2e25 100644 --- a/src/images/artifacts.ts +++ b/src/images/artifacts.ts @@ -1,5 +1,5 @@ import { readdirSync, readFileSync, statSync, unlinkSync, existsSync } from "node:fs"; -import { mkdir, writeFile } from "node:fs/promises"; +import { mkdir, writeFile, open, unlink } from "node:fs/promises"; import type { IncomingMessage } from "node:http"; import https from "node:https"; import type { RequestOptions } from "node:https"; @@ -475,3 +475,147 @@ export async function downloadImageToArtifact( // Retention is post-batch via pruneArtifacts (see fulfill.ts). return writeArtifactUnique(dir, "dl-", bytes, ext); } + +const MAX_VIDEO_DOWNLOAD_BYTES = 200 * 1024 * 1024; // 200 MiB +/** Aggregate per-turn video download cap (600 MiB = 3 × max single download). */ +const MAX_VIDEO_BYTES_PER_TURN = MAX_VIDEO_DOWNLOAD_BYTES * 3; + +export interface VideoBudget { + spent: number; + /** Ceiling on total bytes across all downloads this turn. */ + cap: number; +} + +export function createVideoBudget(): VideoBudget { + return { spent: 0, cap: MAX_VIDEO_BYTES_PER_TURN }; +} + +/** Charge bytes to the budget; returns false if the ceiling would be exceeded. */ +export function chargeVideoBudget(budget: VideoBudget, bytes: number): boolean { + if (budget.spent + bytes > budget.cap) return false; + budget.spent += bytes; + return true; +} + +export function guessVideoExtFromMagic(bytes: Uint8Array): string { + if (bytes.byteLength < 12) throw new Error("video data too short for magic byte sniffing"); + const sig = Buffer.from(bytes.slice(0, 12)).toString("latin1"); + // MP4/QuickTime/MOV: bytes 4-7 == "ftyp" (ISO BMFF) + if (sig.slice(4, 8) === "ftyp") return "mp4"; + // WebM/Matroska: \x1a\x45\xdf\xa3 + if (sig.startsWith("\x1a\x45\xdf\xa3")) return "webm"; + throw new Error("unrecognized video format — magic bytes do not match MP4 or WebM"); +} + +/** + * Download a video from a URL to an artifact file with a 200 MiB hard cap, streaming the body + * to disk to avoid buffering. SSRF protection reuses the same destination policy + pinned HTTPS + * as image downloads. Format is sniffed from magic bytes. + */ +export async function downloadVideoToArtifact( + url: string, + budget?: VideoBudget, + signal?: AbortSignal, +): Promise { + // For data: URLs, handle inline (unlikely for video but keep parity) + if (url.startsWith("data:")) { + const commaIdx = url.indexOf(","); + const meta = url.slice(0, commaIdx); + const data = url.slice(commaIdx + 1); + const isBase64 = meta.includes(";base64"); + if (!isBase64) throw new Error("non-base64 data URI for video is not supported"); + const buf = Buffer.from(data, "base64"); + if (budget && !chargeVideoBudget(budget, buf.byteLength)) { + throw new Error("video data URI exceeds per-turn download budget"); + } + if (buf.byteLength > MAX_VIDEO_DOWNLOAD_BYTES) throw new Error("video data URI exceeds size cap"); + const ext = guessVideoExtFromMagic(buf); + const dir = getArtifactsDir(); + await mkdir(dir, { recursive: true, mode: 0o700 }); + const name = `vid-${timestampPrefix()}-${crypto.randomUUID()}.${ext}`; + const dest = join(dir, name); + await writeFile(dest, buf, { mode: 0o600 }); + return dest; + } + + // SSRF protection: same validation as downloadImageToArtifact + let parsedUrl: URL; + try { parsedUrl = new URL(url); } catch { throw new Error("video URL is not valid"); } + if (parsedUrl.protocol !== "https:") { + throw new Error(`video URL must use HTTPS, got ${parsedUrl.protocol}`); + } + const assessment = assessUrlDestination(url); + if (assessment && assessment.kind !== "public" && assessment.kind !== "hostname") { + throw new Error(`video URL targets ${assessment.detail}`); + } + const resolved = await resolvePublicAddresses(url, "video"); + const pinned = pickPinnedAddress(resolved.addresses); + const resp = await pinnedHttpsGet(url, pinned, signal, { maxBytes: MAX_VIDEO_DOWNLOAD_BYTES }); + if (!resp.ok) { + try { await resp.body?.cancel(); } catch { /* ignore */ } + throw new Error("video download failed: " + resp.status); + } + + const dir = getArtifactsDir(); + await mkdir(dir, { recursive: true, mode: 0o700 }); + + const reader = resp.body?.getReader(); + if (!reader) throw new Error("video download returned no body"); + + // Buffer at least 12 bytes for magic-byte sniffing before opening the file. + // A single read() can return fewer bytes; accumulate until we have enough. + let dest: string | undefined; + let fh: { close(): Promise; writeFile(data: Uint8Array): Promise } | undefined; + try { + const sniffChunks: Uint8Array[] = []; + let sniffLen = 0; + while (sniffLen < 12) { + const { value, done } = await reader.read(); + if (done) break; + if (!value) continue; + sniffChunks.push(value); + sniffLen += value.byteLength; + } + if (sniffLen === 0) { + throw new Error("video download returned empty body"); + } + const sniffBuf = Buffer.concat(sniffChunks); + const ext = guessVideoExtFromMagic(new Uint8Array(sniffBuf)); + const name = `vid-${timestampPrefix()}-${crypto.randomUUID()}.${ext}`; + dest = join(dir, name); + fh = await open(dest, "w", 0o600); + // Write all buffered chunks and set up accounting + let totalBytes = sniffBuf.byteLength; + if (budget && !chargeVideoBudget(budget, totalBytes)) { + throw new Error("video download exceeds per-turn budget"); + } + if (totalBytes > MAX_VIDEO_DOWNLOAD_BYTES) { + throw new Error("video download exceeds size cap"); + } + await fh.writeFile(new Uint8Array(sniffBuf)); + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + totalBytes += value.byteLength; + if (budget && !chargeVideoBudget(budget, value.byteLength)) { + throw new Error("video download exceeds per-turn budget"); + } + if (totalBytes > MAX_VIDEO_DOWNLOAD_BYTES) { + throw new Error("video download exceeds size cap"); + } + await fh.writeFile(value); + } + await fh.close(); + try { await reader.cancel(); } catch { /* ignore */ } + reader.releaseLock(); + return dest; + } catch (err) { + try { await reader.cancel(); } catch { /* ignore */ } + reader.releaseLock(); + if (fh) { + try { await fh.close(); } catch { /* ignore */ } + } + if (dest) await unlink(dest).catch(() => {}); + throw err; + } +} diff --git a/src/images/fulfill-video.ts b/src/images/fulfill-video.ts new file mode 100644 index 000000000..41bda759a --- /dev/null +++ b/src/images/fulfill-video.ts @@ -0,0 +1,163 @@ +import { pathToFileURL } from "node:url"; +import type { VideoBridgePlan, VideoCallResult } from "./types"; +import { submitVideoJob, pollVideoJob } from "./xai-video-client"; +import { downloadVideoToArtifact, createVideoBudget, type VideoBudget } from "./artifacts"; + +/** Parsed arguments from the model's video_gen tool call. */ +export interface ParsedVideoArgs { + ok: true; + prompt: string; + duration?: number; + resolution?: string; + aspectRatio?: string; +} + +const INITIAL_POLL_INTERVAL_MS = 5_000; +const MAX_POLL_INTERVAL_MS = 15_000; +const POLL_BACKOFF = 1.5; +const DEFAULT_VIDEO_TIMEOUT_MS = 300_000; // 5 min + +export type SleepFn = (ms: number, signal?: AbortSignal) => Promise; +export type PollVideoJobFn = ( + requestId: string, + auth: { baseUrl: string; token: string }, + signal?: AbortSignal, +) => ReturnType; + +export function sleep(ms: number, signal?: AbortSignal): Promise { + if (signal?.aborted) return Promise.reject(new Error("aborted")); + return new Promise((resolve, reject) => { + const onAbort = (): void => { clearTimeout(timer); reject(new Error("aborted")); }; + const timer = setTimeout(() => { signal?.removeEventListener("abort", onAbort); resolve(); }, ms); + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +/** + * Parse the JSON arguments string from a video_gen tool call. Mirrors fulfillImageCall's + * defensive parsing: invalid JSON or missing prompt returns an error result. + */ +export function parseVideoCallArgs(raw: string): + | ParsedVideoArgs + | { ok: false; error: string } { + let args: unknown; + try { + args = JSON.parse(raw || "{}"); + } catch { + return { ok: false, error: "invalid arguments JSON" }; + } + if (typeof args !== "object" || args === null) { + return { ok: false, error: "invalid arguments JSON" }; + } + const obj = args as Record; + + const prompt = + typeof obj.prompt === "string" ? obj.prompt : typeof obj.input === "string" ? obj.input : ""; + if (!prompt) { + return { ok: false, error: "missing prompt" }; + } + + const result: ParsedVideoArgs = { ok: true, prompt }; + + // duration: clamp to 1-15 + if (typeof obj.duration === "number") { + result.duration = Math.max(1, Math.min(15, Math.floor(obj.duration))); + } + + if (typeof obj.resolution === "string" && (obj.resolution === "480p" || obj.resolution === "720p")) { + result.resolution = obj.resolution; + } + if (typeof obj.aspect_ratio === "string") { + const VALID_RATIOS = ["16:9", "9:16", "1:1", "4:3", "3:4", "3:2", "2:3"]; + if (VALID_RATIOS.includes(obj.aspect_ratio)) result.aspectRatio = obj.aspect_ratio; + } + + return result; +} + +/** + * Poll a video generation job, yielding heartbeats with elapsed-time messages so the + * caller can forward them to the SSE stream. Returns the final result when the job + * completes or fails/times out. + */ +export async function* pollVideoWithHeartbeats( + requestId: string, + auth: { baseUrl: string; token: string }, + signal: AbortSignal, + timeoutMs: number = DEFAULT_VIDEO_TIMEOUT_MS, + /** Test seams — avoid mock.module / global setTimeout (both poison parallel Bun CI). */ + sleepFn: SleepFn = sleep, + pollFn: PollVideoJobFn = pollVideoJob, +): AsyncGenerator< + { type: "heartbeat"; message: string }, + { ok: true; videoUrl: string } | { ok: false; error: string } +> { + const start = Date.now(); + let interval = INITIAL_POLL_INTERVAL_MS; + const timeoutStr = `video generation timed out after ${Math.floor(timeoutMs / 1000)}s`; + + for (;;) { + if (Date.now() - start >= timeoutMs) { + return { ok: false, error: timeoutStr }; + } + + try { + const poll = await pollFn(requestId, auth, signal); + if (poll.status === "done") { + if (poll.videoUrl) { + return { ok: true, videoUrl: poll.videoUrl }; + } + return { ok: false, error: "video generation completed but no video URL was returned" }; + } + if (poll.status === "failed") { + return { ok: false, error: "video generation failed" }; + } + if (poll.status === "expired") { + return { ok: false, error: "video generation expired" }; + } + // still processing — continue with backoff + } catch (e) { + // Distinguish deadline-expiry from client-cancel. + // AbortSignal.timeout sets signal.reason to a TimeoutError. + const isDeadline = signal.aborted && e instanceof Error && e.name === "TimeoutError"; + if (isDeadline) return { ok: false, error: timeoutStr }; + if (signal.aborted) return { ok: false, error: "client closed request during video generation" }; + const status = (e as Error & { status?: number }).status; + if (status && status >= 400 && status < 500 && status !== 429) { + return { ok: false, error: `video poll failed permanently: HTTP ${status}` }; + } + console.warn(`[videos] poll error (will retry): ${e instanceof Error ? e.message : String(e)}`); + } + + const elapsed = Math.floor((Date.now() - start) / 1000); + yield { type: "heartbeat", message: `Generating video... ${elapsed}s` }; + + try { + await sleepFn(interval, signal); + } catch { + const isDeadline = signal.aborted && signal.reason instanceof Error && signal.reason.name === "TimeoutError"; + if (isDeadline) return { ok: false, error: timeoutStr }; + return { ok: false, error: "client closed request during video generation" }; + } + + interval = Math.min(MAX_POLL_INTERVAL_MS, Math.floor(interval * POLL_BACKOFF)); + } +} + +/** + * Build a success VideoCallResult after the video has been downloaded to disk. + */ +export function buildVideoResult(path: string, prompt: string, model: string): VideoCallResult { + return { + ok: true, + model, + prompt, + path, + files: [path], + count: 1, + markdown: `[video](${pathToFileURL(path).href})`, + }; +} + +export type { VideoBudget }; +export { createVideoBudget }; diff --git a/src/images/index.ts b/src/images/index.ts index fc1f2bff1..f9f0fd5b9 100644 --- a/src/images/index.ts +++ b/src/images/index.ts @@ -1,4 +1,4 @@ -export { planImageBridge, findXaiProvider, resolveXaiImageApiKey } from "./plan"; +export { planImageBridge, planVideoBridge, findXaiProvider, resolveXaiImageApiKey } from "./plan"; export { runWithImageBridge, clampImageMaxRounds, DEFAULT_MAX_ROUNDS, MAX_ROUNDS_HARD_LIMIT } from "./loop"; -export type { ImageBridgePlan, ImageCallResult } from "./types"; -export { buildImageTool, extractHostedImageGeneration, IMAGE_GEN_TOOL_NAME, isImageGenName } from "./synthetic-tool"; +export type { ImageBridgePlan, ImageCallResult, VideoBridgePlan, VideoCallResult } from "./types"; +export { buildImageTool, buildVideoTool, extractHostedImageGeneration, IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME, isImageGenName, isVideoGenName } from "./synthetic-tool"; diff --git a/src/images/loop.ts b/src/images/loop.ts index e9240f74a..b98e5b9e7 100644 --- a/src/images/loop.ts +++ b/src/images/loop.ts @@ -1,16 +1,18 @@ /** - * Image bridge agentic loop — adapted from src/web-search/loop.ts but significantly simpler. + * Media bridge agentic loop — supports both image and video generation sidecars. * * The routed (non-OpenAI) model runs in a bounded loop. Each iteration is streamed and fully - * buffered internally. If the model calls an image-generation tool, the bridge fulfills it via - * the xAI sidecar, injects the result as a tool_result, and loops (bounded by maxRounds). When - * the model produces a real tool call or the budget is exhausted, the passthrough events are - * replayed to the bridge for final SSE output. + * buffered internally. If the model calls an image-generation or video-generation tool, the + * bridge fulfills it via the xAI sidecar, injects the result as a tool_result, and loops + * (bounded by maxRounds). When the model produces a real tool call or the budget is exhausted, + * the passthrough events are replayed to the bridge for final SSE output. * * Removed vs web-search: no sidecar backend selection, no forced-answer nudge, no failed-query * dedup, no describeImages/structuredOutput, no recordSidecarOutcome. */ import type { AdapterRequest, ProviderAdapter } from "../adapters/base"; +import { existsSync } from "node:fs"; +import { pathToFileURL } from "node:url"; import { createAdapterEventQueue } from "../adapters/run-turn-queue"; import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxProviderContinuationState, OcxRequestOptions, OcxThinkingContent, OcxUsage } from "../types"; import { namespacedToolName } from "../types"; @@ -20,9 +22,11 @@ import { readBoundedResponseBody } from "../lib/bounded-body"; import { fetchWithResetRetry } from "../lib/upstream-retry"; import { parseStreamWithProgress, RoutedModelInactivityError, WebSearchStreamProtocolError } from "../web-search/progress-stream"; import { fulfillImageCall } from "./fulfill"; -import { createImageBudget } from "./artifacts"; -import { IMAGE_GEN_TOOL_NAME } from "./synthetic-tool"; -import type { ImageBridgePlan } from "./types"; +import { parseVideoCallArgs, pollVideoWithHeartbeats, buildVideoResult, createVideoBudget } from "./fulfill-video"; +import { submitVideoJob } from "./xai-video-client"; +import { downloadVideoToArtifact, createImageBudget, pruneArtifacts } from "./artifacts"; +import { IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME } from "./synthetic-tool"; +import type { ImageBridgePlan, VideoBridgePlan } from "./types"; const SSE_HEADERS = { "Content-Type": "text/event-stream", @@ -36,8 +40,10 @@ const STALL_TIMEOUT_MS = 200_000; export const DEFAULT_MAX_ROUNDS = 3; /** Absolute ceiling so a hand-edited `images.maxRounds: 10000` cannot unbound paid xAI calls. */ export const MAX_ROUNDS_HARD_LIMIT = 10; -/** Cap paid xAI fulfillments per turn (parallel calls in one round count separately). */ +/** Cap paid xAI image fulfillments per turn (parallel calls in one round count separately). */ export const MAX_IMAGE_CALLS_PER_TURN = 10; +/** Cap paid xAI video fulfillments per turn (video is slower/costlier than image). */ +export const MAX_VIDEO_CALLS_PER_TURN = 3; /** * Clamp a configured maxRounds value to a safe integer in [0, MAX_ROUNDS_HARD_LIMIT]. @@ -48,23 +54,27 @@ export function clampImageMaxRounds(value: unknown): number { return Math.max(0, Math.min(MAX_ROUNDS_HARD_LIMIT, Math.floor(value))); } -/** Drop image-specific tool_choice when image tools are stripped for a forced-final pass. */ -function stripImageToolChoice( +/** Drop image/video-specific tool_choice when media tools are stripped for a forced-final pass. */ +function stripMediaToolChoice( options: OcxRequestOptions, - plan: ImageBridgePlan, + plan?: ImageBridgePlan, + videoPlan?: VideoBridgePlan, ): OcxRequestOptions { const tc = options.toolChoice; if (!tc || typeof tc !== "object") return options; + const isMediaTool = (name: string): boolean => + name === IMAGE_GEN_TOOL_NAME || + name === VIDEO_GEN_TOOL_NAME || + (plan?.toolNames.has(name) ?? false) || + (videoPlan?.toolNames.has(name) ?? false); if ("name" in tc && typeof tc.name === "string") { - if (tc.name === IMAGE_GEN_TOOL_NAME || plan.toolNames.has(tc.name)) { + if (isMediaTool(tc.name)) { return { ...options, toolChoice: "auto" }; } return options; } if ("allowedTools" in tc && Array.isArray(tc.allowedTools)) { - const filtered = tc.allowedTools.filter( - (name) => name !== IMAGE_GEN_TOOL_NAME && !plan.toolNames.has(name), - ); + const filtered = tc.allowedTools.filter(name => !isMediaTool(name)); if (filtered.length === tc.allowedTools.length) return options; if (filtered.length === 0) return { ...options, toolChoice: "auto" }; return { ...options, toolChoice: { ...tc, allowedTools: filtered } }; @@ -190,7 +200,10 @@ class LoopError extends Error { export interface ImageBridgeDeps { parsed: OcxParsedRequest; adapter: ProviderAdapter; - plan: ImageBridgePlan; + plan?: ImageBridgePlan; + videoPlan?: VideoBridgePlan; + /** Per-video generation timeout (ms) including polling. */ + videoTimeoutMs?: number; /** Headers forwarded from the original request (e.g. Codex auth). Cloned per iteration. */ forwardHeaders?: Headers; /** Called before each routed-model dispatch in the bridge loop, for attempt telemetry. */ @@ -227,7 +240,7 @@ export interface ImageBridgeDeps { * inject the answer as a tool_result, and loop (bounded by `maxRounds`). */ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise { - const { parsed, plan, abortSignal } = deps; + const { parsed, plan, videoPlan, videoTimeoutMs, abortSignal } = deps; let adapter = deps.adapter; const maxRounds = clampImageMaxRounds(deps.maxRounds ?? DEFAULT_MAX_ROUNDS); const HARD_CAP = maxRounds + 1; @@ -239,6 +252,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise { @@ -275,17 +289,24 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise { + // Merge tool names from both plans for event scanning. + const mediaToolNames = new Set(); + if (plan) for (const n of plan.toolNames) mediaToolNames.add(n); + if (videoPlan) for (const n of videoPlan.toolNames) mediaToolNames.add(n); + // Forced-final must strip every image/video-generation alias the plans know about — not only + // tools flagged `imageGeneration:true` or `videoGeneration:true`. Hosted `image_generation` / + // function aliases would otherwise remain callable; scanEventsForImageCall would strip the + // call while forceFinal blocks fulfillment, leaving the client an empty completion. + const toolsNoMedia = allTools.filter(t => { if (t.imageGeneration) return false; - if (plan.toolNames.has(t.name)) return false; - if (t.namespace && plan.toolNames.has(namespacedToolName(t.namespace, t.name))) return false; + if (t.videoGeneration) return false; + if (plan && plan.toolNames.has(t.name)) return false; + if (plan && t.namespace && plan.toolNames.has(namespacedToolName(t.namespace, t.name))) return false; + if (videoPlan && videoPlan.toolNames.has(t.name)) return false; return true; }); const budget = createImageBudget(); + const vBudget = createVideoBudget(); // Link an internal AbortController to the turn signal so a client cancel of the SSE body aborts // in-flight model fetches AND the sidecar. @@ -310,8 +331,8 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise>; args: Record }> = []; for (const call of split.calls) { - yield { type: "heartbeat" }; - let result: Awaited>; - if (paidImageCalls >= MAX_IMAGE_CALLS_PER_TURN) { - result = { - ok: false, - model: plan.model, - prompt: "", - files: [], - count: 0, - error: `image call budget exhausted (max ${MAX_IMAGE_CALLS_PER_TURN} per turn)`, - }; + const isVideoCall = videoPlan?.toolNames.has(call.name) === true; + if (isVideoCall) { + yield { type: "heartbeat" }; + if (paidVideoCalls >= MAX_VIDEO_CALLS_PER_TURN) { + const vResult = { + ok: false, model: videoPlan!.model, prompt: "", files: [], count: 0, + error: `video call budget exhausted (max ${MAX_VIDEO_CALLS_PER_TURN} per turn)`, + }; + let pArgs: Record = {}; + try { const raw: unknown = JSON.parse(call.args || "{}"); if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) pArgs = raw as Record; } catch { /* malformed args */ } + fulfilled.push({ call, result: vResult, args: pArgs }); + continue; + } + const vArgs = parseVideoCallArgs(call.args); + let vResult; + if (!vArgs.ok) { + vResult = { ok: false, model: videoPlan!.model, prompt: "", files: [], count: 0, error: vArgs.error }; + } else { + paidVideoCalls += 1; + const videoTimeout = videoTimeoutMs ?? 300_000; + const deadlineSignal = AbortSignal.timeout(videoTimeout); + try { + const videoDeadline = Date.now() + videoTimeout; + // Bind submit to the shared deadline so submit + poll fit one budget. + const linkedDeadline = signal + ? AbortSignal.any([signal, deadlineSignal]) + : deadlineSignal; + const { requestId } = await submitVideoJob( + { + prompt: vArgs.prompt, model: videoPlan!.model, + ...(vArgs.duration != null ? { duration: vArgs.duration } : {}), + ...(vArgs.resolution != null ? { resolution: vArgs.resolution } : {}), + ...(vArgs.aspectRatio != null ? { aspectRatio: vArgs.aspectRatio } : {}), + }, + videoPlan!.auth, linkedDeadline, + ); + const remainingMs = videoDeadline - Date.now(); + if (remainingMs <= 0) { + vResult = { ok: false, model: videoPlan!.model, prompt: vArgs.prompt, files: [], count: 0, error: `video generation timed out after ${Math.floor(videoTimeout / 1000)}s` }; + } else { + const pollGen = pollVideoWithHeartbeats(requestId, videoPlan!.auth, linkedDeadline, remainingMs); + let pollResult: { ok: true; videoUrl: string } | { ok: false; error: string }; + try { + for (;;) { + const { value, done } = await pollGen.next(); + if (done) { pollResult = value; break; } + yield { type: "heartbeat" }; + } + } finally { + await pollGen.return({ ok: false, error: "cancelled" }).catch(() => {}); + } + if (signal.aborted) throw new LoopError(499, "client closed request during video-bridge"); + if (pollResult.ok) { + const dlPath = await downloadVideoToArtifact(pollResult.videoUrl, vBudget, signal); + vResult = buildVideoResult(dlPath, vArgs.prompt, videoPlan!.model); + } else { + vResult = { ok: false, model: videoPlan!.model, prompt: vArgs.prompt, files: [], count: 0, error: pollResult.error }; + } + } + } catch (e) { + if (deadlineSignal.aborted && !signal.aborted) { + vResult = { ok: false, model: videoPlan!.model, prompt: vArgs.prompt ?? "", files: [], count: 0, error: `video generation timed out after ${Math.floor(videoTimeout / 1000)}s` }; + } else if (signal.aborted) { + throw new LoopError(499, "client closed request during video-bridge"); + } else { + const error = e instanceof Error ? e.message : String(e); + vResult = { ok: false, model: videoPlan!.model, prompt: vArgs.prompt ?? "", files: [], count: 0, error }; + } + } + } + if (signal.aborted) throw new LoopError(499, "client closed request during video-bridge"); + let vParsedArgs: Record = {}; + try { const raw: unknown = JSON.parse(call.args || "{}"); if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) vParsedArgs = raw as Record; } catch { /* malformed args */ } + fulfilled.push({ call, result: vResult, args: vParsedArgs }); } else { - paidImageCalls += 1; - result = await fulfillImageCall( - { id: call.id, name: call.name, arguments: call.args }, - plan, budget, signal, - ); + yield { type: "heartbeat" }; + if (!plan) { + fulfilled.push({ call, result: { ok: false, model: "", prompt: "", files: [], count: 0, error: "image bridge not configured" }, args: {} }); + continue; + } + let result: Awaited>; + if (paidImageCalls >= MAX_IMAGE_CALLS_PER_TURN) { + result = { + ok: false, + model: plan.model, + prompt: "", + files: [], + count: 0, + error: `image call budget exhausted (max ${MAX_IMAGE_CALLS_PER_TURN} per turn)`, + }; + } else { + paidImageCalls += 1; + result = await fulfillImageCall( + { id: call.id, name: call.name, arguments: call.args }, + plan, budget, signal, + ); + } + if (signal.aborted) throw new LoopError(499, "client closed request during image-bridge"); + let parsedArgs: Record = {}; + try { + const raw: unknown = JSON.parse(call.args || "{}"); + if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) { + parsedArgs = raw as Record; + } + } catch { /* malformed args */ } + fulfilled.push({ call, result, args: parsedArgs }); } - if (signal.aborted) throw new LoopError(499, "client closed request during image-bridge"); - let parsedArgs: Record = {}; - try { - const raw: unknown = JSON.parse(call.args || "{}"); - if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) { - parsedArgs = raw as Record; + } + // Prune artifacts once after the entire batch so a tight keepCount + // cannot delete a video from an earlier call in this same iteration. + pruneArtifacts(videoPlan?.artifactsKeepCount ?? plan?.artifactsKeepCount); + // Drop results whose artifact files were pruned — never hand the model a dead path. + for (const f of fulfilled) { + if (!f.result.ok || !f.result.files || f.result.files.length === 0) continue; + const survivors = f.result.files.filter(p => existsSync(p)); + if (survivors.length === f.result.files.length) continue; // nothing pruned + if (survivors.length === 0) { + f.result = { + ok: false, model: f.result.model, prompt: f.result.prompt ?? "", + files: [], count: 0, + error: "artifact was pruned before delivery (increase artifactsKeepCount)", + } as typeof f.result; + } else { + // Some files survived — refresh from survivors + f.result = { ...f.result, files: survivors, count: survivors.length }; + const primary = survivors[0]!; + (f.result as { path?: string }).path = primary; + if ("markdown" in f.result && f.result.markdown) { + // Image markdown references the primary path; video uses pathToFileURL + if (f.result.markdown.startsWith("![")) { + (f.result as { markdown: string }).markdown = `![image](${pathToFileURL(primary).href})`; + } else { + (f.result as { markdown: string }).markdown = `[video](${pathToFileURL(primary).href})`; + } } - } catch { /* malformed args */ } - fulfilled.push({ call, result, args: parsedArgs }); + } } const now = Date.now(); messages.push({ diff --git a/src/images/plan.ts b/src/images/plan.ts index ce59a67e5..b706b5756 100644 --- a/src/images/plan.ts +++ b/src/images/plan.ts @@ -1,8 +1,8 @@ import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../types"; -import type { ImageBridgePlan } from "./types"; +import type { ImageBridgePlan, VideoBridgePlan } from "./types"; import { resolveEnvValue } from "../config"; import { getProviderRegistryEntry } from "../providers/registry"; -import { IMAGE_GEN_TOOL_NAME } from "./synthetic-tool"; +import { IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME, isVideoGenName } from "./synthetic-tool"; const DEFAULT_MODEL = "grok-imagine-image-quality"; /** Absolute ceiling for `images.timeoutMs` (matches /v1/images relay budget). */ @@ -78,3 +78,56 @@ export async function planImageBridge( ...(artifactsKeepCount !== undefined ? { artifactsKeepCount } : {}), }; } + +const DEFAULT_VIDEO_MODEL = "grok-imagine-video"; + +/** + * Decide whether the video bridge should activate for this request. Unlike images, video + * generation has no hosted OpenAI tool type — the synthetic `video_gen` tool is unconditionally + * injected when `videoBridgeEnabled` is true. The bridge activates only when: + * 1. videoBridgeEnabled is explicitly true (opt-in) + * 2. the routed provider is NOT api.openai.com (native passthrough) + * 3. an xAI provider with a valid API key is available + */ +export async function planVideoBridge( + config: OcxConfig, + parsed: OcxParsedRequest, + routedProvider: OcxProviderConfig, +): Promise { + if (config.images?.videoBridgeEnabled !== true) return undefined; + // Don't intercept for OpenAI native passthrough + const host = (() => { try { return new URL(routedProvider.baseUrl).hostname; } catch { return ""; } })(); + if (host === "api.openai.com") return undefined; + const found = findXaiProvider(config); + if (!found) return undefined; + const token = resolveXaiImageApiKey(found.provider); + if (!token) return undefined; + // Pin the baseUrl to the registry entry, ignoring any config-level baseUrl override. + const registryEntry = getProviderRegistryEntry("xai"); + const pinnedBaseUrl = (registryEntry?.baseUrl ?? "https://api.x.ai/v1").replace(/\/+$/, ""); + const toolNames = new Set(); + toolNames.add(VIDEO_GEN_TOOL_NAME); + // Collect any existing function tools whose name matches a video_gen alias + // so the loop can intercept and replace them (image-bridge parity). + for (const t of parsed.context?.tools ?? []) { + // Skip namespaced tools — a namespaced MCP video_gen must not be intercepted. + if (t.namespace) continue; + const fnName = typeof t.name === "string" ? t.name + : (t as unknown as { function?: { name?: string } }).function?.name; + if (typeof fnName === "string" && isVideoGenName(fnName)) { + toolNames.add(fnName); + } + } + const timeoutMs = clampImageTimeoutMs(config.images?.videoTimeoutMs); + const keepRaw = config.images?.artifactsKeepCount; + const artifactsKeepCount = + typeof keepRaw === "number" && Number.isFinite(keepRaw) ? Math.floor(keepRaw) : undefined; + return { + provider: found.provider, + auth: { baseUrl: pinnedBaseUrl, token }, + model: config.images?.videoBridgeModel ?? DEFAULT_VIDEO_MODEL, + toolNames, + ...(timeoutMs !== undefined ? { timeoutMs } : {}), + ...(artifactsKeepCount !== undefined ? { artifactsKeepCount } : {}), + }; +} diff --git a/src/images/synthetic-tool.ts b/src/images/synthetic-tool.ts index 8ba1fbda4..84ac536ab 100644 --- a/src/images/synthetic-tool.ts +++ b/src/images/synthetic-tool.ts @@ -86,3 +86,48 @@ export function buildImageTool(): OcxTool { imageGeneration: true, }; } + +/** The function name the chat model sees for video generation. */ +export const VIDEO_GEN_TOOL_NAME = "video_gen"; + +const VIDEO_GEN_NAMES = new Set([ + "video_gen", + "video_generation", + "videogen", + "generate_video", + "generatevideo", +]); + +export function isVideoGenName(name: string): boolean { + return VIDEO_GEN_NAMES.has(name.toLowerCase()); +} + +/** + * The synthetic function tool exposed to a chat/anthropic model for video generation. + * Unlike image_generation, there is no hosted OpenAI tool type for video — this tool is + * unconditionally injected when the video bridge is enabled. `videoGeneration:true` flags it + * so the forced-final pass can drop it. + */ +export function buildVideoTool(): OcxTool { + return { + name: VIDEO_GEN_TOOL_NAME, + description: + "Generate a short video (1-15 seconds) from a text prompt. Returns an absolute local filesystem path. " + + "Use when the user asks to create, animate, or generate a video.", + parameters: { + type: "object", + properties: { + prompt: { type: "string", description: "Detailed video generation prompt. Required." }, + duration: { type: "integer", minimum: 1, maximum: 15, description: "Video length in seconds. Default 6." }, + resolution: { type: "string", enum: ["480p", "720p"], description: "Video resolution. Default 720p." }, + aspect_ratio: { + type: "string", + enum: ["16:9", "9:16", "1:1", "4:3", "3:4", "3:2", "2:3"], + description: "Aspect ratio. Default 16:9.", + }, + }, + required: ["prompt"], + }, + videoGeneration: true, + }; +} diff --git a/src/images/types.ts b/src/images/types.ts index e72177746..d9176aacb 100644 --- a/src/images/types.ts +++ b/src/images/types.ts @@ -24,3 +24,18 @@ export interface ImageCallResult { markdown?: string; error?: string; } + +/** Plan for the video bridge. Same auth/provider shape as image, without image-specific defaults. */ +export interface VideoBridgePlan { + provider: OcxProviderConfig; + auth: { baseUrl: string; token: string }; + model: string; + toolNames: Set; + /** Per-call xAI deadline (ms) for submit + poll. */ + timeoutMs?: number; + /** Max artifact files to retain (from config.images.artifactsKeepCount). Default 200. ≤0 disables prune. */ + artifactsKeepCount?: number; +} + +/** Result shape for video fulfillment — same as image. */ +export type VideoCallResult = ImageCallResult; diff --git a/src/images/xai-video-client.ts b/src/images/xai-video-client.ts new file mode 100644 index 000000000..1a9843341 --- /dev/null +++ b/src/images/xai-video-client.ts @@ -0,0 +1,163 @@ +/** + * xAI video generation client. + * + * Video generation is asynchronous: a POST to `/videos/generations` returns a `request_id`, + * which is then polled via GET `/videos/{request_id}` until the status is terminal. + * The submit call uses a 60 s timeout (it only returns an ID); the poll call uses 30 s. + * Non-2xx responses throw with the original status code so callers can distinguish + * rate-limit / auth failures from transient errors. + */ + +export interface XaiVideoSubmitRequest { + prompt: string; + model?: string; // default "grok-imagine-video" + duration?: number; // 1-15 seconds + resolution?: string; // "480p" | "720p" + aspectRatio?: string; // "16:9" | "9:16" | "1:1" | "4:3" | "3:4" | "3:2" | "2:3" +} + +export interface XaiVideoSubmitResult { + requestId: string; +} + +export interface XaiVideoPollResult { + status: "processing" | "done" | "failed" | "expired"; + videoUrl?: string; + progress?: number; +} + +const SUBMIT_TIMEOUT_MS = 60_000; +const POLL_TIMEOUT_MS = 30_000; +const DEFAULT_VIDEO_MODEL = "grok-imagine-video"; + +/** Maximum response body size for the submit and poll calls (responses are small JSON). */ +const MAX_RESPONSE_BYTES = 1024 * 1024; // 1 MiB — these endpoints return small JSON + +/** + * Read an HTTP response body as text with a hard byte cap. Used for the non-streaming + * video submit/poll endpoints. + */ +async function readBoundedText(resp: Response): Promise { + const reader = resp.body?.getReader(); + if (!reader) throw new Error("xAI video API returned no body"); + const decoder = new TextDecoder(); + let text = ""; + let totalBytes = 0; + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + totalBytes += value.byteLength; + if (totalBytes > MAX_RESPONSE_BYTES) throw new Error("xAI video API response exceeds size cap"); + text += decoder.decode(value, { stream: true }); + } + text += decoder.decode(); + } finally { + try { await reader.cancel(); } catch { /* ignore */ } + reader.releaseLock(); + } + return text; +} + +/** + * Submit a video generation job to xAI. Returns the `request_id` used for polling. + */ +export async function submitVideoJob( + req: XaiVideoSubmitRequest, + auth: { baseUrl: string; token: string }, + signal?: AbortSignal, +): Promise { + const body: Record = { + model: req.model ?? DEFAULT_VIDEO_MODEL, + prompt: req.prompt, + }; + if (typeof req.duration === "number") body.duration = req.duration; + if (typeof req.resolution === "string") body.resolution = req.resolution; + if (typeof req.aspectRatio === "string") body.aspect_ratio = req.aspectRatio; + + const timeout = AbortSignal.timeout(SUBMIT_TIMEOUT_MS); + const linkedSignal = signal ? AbortSignal.any([signal, timeout]) : timeout; + + const resp = await fetch(`${auth.baseUrl}/videos/generations`, { + method: "POST", + headers: { + "Authorization": `Bearer ${auth.token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + signal: linkedSignal, + }); + + if (!resp.ok) { + try { await resp.body?.cancel(); } catch { /* ignore */ } + const err = new Error("xAI videos API returned " + resp.status) as Error & { status: number }; + err.status = resp.status; + throw err; + } + + const text = await readBoundedText(resp); + const json = JSON.parse(text) as { request_id?: string; id?: string }; + const requestId = json.request_id ?? json.id; + if (typeof requestId !== "string") { + throw new Error("xAI videos API did not return a request_id"); + } + return { requestId }; +} + +/** + * Poll the status of a video generation job. Call this repeatedly with backoff until + * `status` is `"done"` (video ready) or a terminal failure state. + */ +export async function pollVideoJob( + requestId: string, + auth: { baseUrl: string; token: string }, + signal?: AbortSignal, +): Promise { + const timeout = AbortSignal.timeout(POLL_TIMEOUT_MS); + const linkedSignal = signal ? AbortSignal.any([signal, timeout]) : timeout; + + const resp = await fetch(`${auth.baseUrl}/videos/${encodeURIComponent(requestId)}`, { + method: "GET", + headers: { + "Authorization": `Bearer ${auth.token}`, + }, + signal: linkedSignal, + }); + + if (!resp.ok) { + try { await resp.body?.cancel(); } catch { /* ignore */ } + const err = new Error("xAI videos poll API returned " + resp.status) as Error & { status: number }; + err.status = resp.status; + throw err; + } + + const text = await readBoundedText(resp); + const json = JSON.parse(text) as { + status?: string; + state?: string; + video?: { url?: string }; + videos?: Array<{ url?: string }>; + progress?: number; + }; + + // Normalize status — xAI uses "done"/"processing"/"failed"/"expired" but be lenient. + const rawStatus = (json.status ?? json.state ?? "").toLowerCase(); + let status: XaiVideoPollResult["status"]; + if (rawStatus === "done" || rawStatus === "completed" || rawStatus === "succeeded") { + status = "done"; + } else if (rawStatus === "failed" || rawStatus === "error") { + status = "failed"; + } else if (rawStatus === "expired" || rawStatus === "timeout") { + status = "expired"; + } else { + status = "processing"; + } + + const videoUrl = json.video?.url ?? json.videos?.[0]?.url; + + return { + status, + ...(typeof videoUrl === "string" ? { videoUrl } : {}), + ...(typeof json.progress === "number" ? { progress: json.progress } : {}), + }; +} diff --git a/src/lib/destination-policy.ts b/src/lib/destination-policy.ts index b2731df68..c2fec9731 100644 --- a/src/lib/destination-policy.ts +++ b/src/lib/destination-policy.ts @@ -219,7 +219,7 @@ export function assessUrlDestination(url: string): UrlDestinationAssessment | nu * so callers can pin the connect peer and avoid a second, rebindable resolution. * DNS resolution failures are treated as unsafe (fail-closed). */ -export async function resolvePublicAddresses(url: string): Promise<{ +export async function resolvePublicAddresses(url: string, noun: string = "image"): Promise<{ hostname: string; addresses: { address: string; family: number }[]; }> { @@ -227,12 +227,12 @@ export async function resolvePublicAddresses(url: string): Promise<{ try { hostname = normalizeHostname(new URL(url.trim()).hostname); } catch { - throw new Error("image URL is not a valid URL"); + throw new Error(`${noun} URL is not a valid URL`); } - if (!hostname) throw new Error("image URL has no hostname"); + if (!hostname) throw new Error(`${noun} URL has no hostname`); const literalAssessment = assessDestination(url); if (literalAssessment && literalAssessment.kind !== "public" && literalAssessment.kind !== "hostname") { - throw new Error(`image URL targets ${literalAssessment.detail}`); + throw new Error(`${noun} URL targets ${literalAssessment.detail}`); } // Literal public IPs: no DNS round-trip; pin the literal itself. const literalKind = isIP(hostname); @@ -245,10 +245,10 @@ export async function resolvePublicAddresses(url: string): Promise<{ } catch { // If DNS fails, we can't verify — fail-closed (unlike provider config-time validation, // this is a runtime fetch to an untrusted URL, so be conservative). - throw new Error(`image URL hostname ${hostname} could not be resolved`); + throw new Error(`${noun} URL hostname ${hostname} could not be resolved`); } if (addresses.length === 0) { - throw new Error(`image URL hostname ${hostname} could not be resolved`); + throw new Error(`${noun} URL hostname ${hostname} could not be resolved`); } const publicAddresses: { address: string; family: number }[] = []; for (const { address, family } of addresses) { @@ -257,7 +257,7 @@ export async function resolvePublicAddresses(url: string): Promise<{ const ipKind = isIP(address) || (family === 4 || family === 6 ? family : 0); const assessment = ipKind === 4 ? classifyIpv4(address) : ipKind === 6 ? classifyIpv6(normalizeHostname(address)) : null; if (!assessment || assessment.kind !== "public") { - throw new Error(`image URL hostname ${hostname} resolves to ${assessment?.detail ?? "an unsafe address"} (${address})`); + throw new Error(`${noun} URL hostname ${hostname} resolves to ${assessment?.detail ?? "an unsafe address"} (${address})`); } publicAddresses.push({ address, family: ipKind === 4 || ipKind === 6 ? ipKind : (family || 4) }); } diff --git a/src/lib/windows-secret-acl.ts b/src/lib/windows-secret-acl.ts index 7cdc48a85..e5ce2eee9 100644 --- a/src/lib/windows-secret-acl.ts +++ b/src/lib/windows-secret-acl.ts @@ -24,6 +24,10 @@ * icacls cannot block OAuth logins or token refresh (field report: Kimi auth * stuck behind ETIMEDOUT). Real EPERM/EACCES/exit-code failures still throw: * availability never silently overrides confidentiality for those. + * hardenSecretPathAsync / hardenSecretDirAsync — same policy, async icacls + * runner so the event loop is not held for the child lifetime (#612). + * HardenOptions.timeoutMemoKey — optional destination-path key for the + * timeout memo (atomic writers mint unique temps; never a parent directory). * hardenSecretDir — same contract for directories. */ @@ -42,6 +46,13 @@ export interface HardenResult { export interface HardenOptions { required: boolean; + /** + * Optional timeout-memo key distinct from `targetPath` (issue #612). + * Atomic writers mint a fresh `.tmp` path per write; keying the timeout cache by the + * final destination path prevents re-stalling the event loop on every subsequent temp. + * Must NOT be a parent directory — directory ACLs are not authoritative for new files. + */ + timeoutMemoKey?: string; } /** @@ -72,6 +83,7 @@ export interface IcaclsResult { } type IcaclsRunner = (args: string[], timeoutMs: number) => IcaclsResult; +type AsyncIcaclsRunner = (args: string[], timeoutMs: number) => Promise; function defaultIcaclsRunner(args: string[], timeoutMs: number): IcaclsResult { // Bun.spawnSync with windowsHide: Node execFileSync has hung under the GUI/proxy even @@ -91,7 +103,43 @@ function defaultIcaclsRunner(args: string[], timeoutMs: number): IcaclsResult { }; } +/** + * Async icacls runner (#612): yields the event loop while waiting for the child. + * Timeout provenance is recorded by our timer (async Subprocess has no exitedDueToTimeout); + * we still await process exit before classifying so settlement is confirmed. + */ +async function defaultAsyncIcaclsRunner(args: string[], timeoutMs: number): Promise { + const proc = Bun.spawn(["icacls.exe", ...args], { + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + windowsHide: true, + }); + let timedOutByUs = false; + const timer = setTimeout(() => { + timedOutByUs = true; + try { proc.kill(); } catch { /* already exited */ } + }, Math.max(1, timeoutMs)); + let exitCode: number | null = null; + try { + exitCode = await proc.exited; + } finally { + clearTimeout(timer); + } + const stdout = proc.stdout + ? await new Response(proc.stdout).text().catch(() => "") + : ""; + const timedOut = timedOutByUs; + return { + success: !timedOut && exitCode === 0, + exitCode: timedOut ? null : exitCode, + timedOut, + stdout, + }; +} + let icaclsRunner: IcaclsRunner = defaultIcaclsRunner; +let asyncIcaclsRunner: AsyncIcaclsRunner = defaultAsyncIcaclsRunner; let platformOverride: string | null = null; let nowFn: () => number = Date.now; @@ -100,6 +148,11 @@ export function setIcaclsRunnerForTests(runner: IcaclsRunner | null): void { icaclsRunner = runner ?? defaultIcaclsRunner; } +/** Test seam: replace the async icacls runner. Pass null to restore the default. */ +export function setAsyncIcaclsRunnerForTests(runner: AsyncIcaclsRunner | null): void { + asyncIcaclsRunner = runner ?? defaultAsyncIcaclsRunner; +} + /** Test seam: force the platform gate (e.g. "win32") so CI on POSIX reaches the runner. */ export function setPlatformForTests(value: string | null): void { platformOverride = value; @@ -156,6 +209,10 @@ function currentWindowsUser(): string | undefined { */ const BROAD_SIDS = ["*S-1-1-0", "*S-1-5-11", "*S-1-5-32-545"] as const; +function grantAce(user: string, directory: boolean): string { + return directory ? `${user}:(OI)(CI)(F)` : `${user}:(F)`; +} + function runIcacls(targetPath: string, directory: boolean, deadline: number): void { const user = currentWindowsUser(); if (!user) { @@ -177,8 +234,7 @@ function runIcacls(targetPath: string, directory: boolean, deadline: number): vo // Step 1: grant current user full control BEFORE any destructive ACL change. // If this fails, inheritance is untouched and the writer keeps inherited access. - const grant = directory ? `${user}:(OI)(CI)(F)` : `${user}:(F)`; - runOrThrow("/grant:r", [targetPath, "/grant:r", grant]); + runOrThrow("/grant:r", [targetPath, "/grant:r", grantAce(user, directory)]); // Step 2: disable inheritance and remove inherited ACEs. The explicit owner ACE // from step 1 survives this transition, so a later failure still leaves cleanup access. @@ -205,6 +261,41 @@ function runIcacls(targetPath: string, directory: boolean, deadline: number): vo } } +/** Async counterpart of runIcacls — same step order and timeout/error classification (#612). */ +async function runIcaclsAsync(targetPath: string, directory: boolean, deadline: number): Promise { + const user = currentWindowsUser(); + if (!user) { + throw new Error("Cannot determine current Windows user for ACL hardening"); + } + + const run = async (step: string, args: string[]): Promise => { + const remaining = deadline - nowFn(); + if (remaining <= 0) { + throw icaclsError(step, { success: false, exitCode: null, timedOut: true, stdout: "" }); + } + return asyncIcaclsRunner(args, remaining); + }; + const runOrThrow = async (step: string, args: string[]): Promise => { + const result = await run(step, args); + if (!result.success) throw icaclsError(step, result); + }; + + await runOrThrow("/grant:r", [targetPath, "/grant:r", grantAce(user, directory)]); + await runOrThrow("/inheritance:r", [targetPath, "/inheritance:r"]); + + const removal = await run("/remove:g", [targetPath, "/remove:g", ...BROAD_SIDS]); + if (!removal.success) { + if (removal.timedOut) throw icaclsError("/remove:g", removal); + for (const sid of BROAD_SIDS) { + const found = await run("/findsid", [targetPath, "/findsid", sid]); + if (!found.success) throw icaclsError("/findsid", found); + if (found.stdout.includes(targetPath)) { + throw icaclsError("/remove:g", removal); + } + } + } +} + /** * Sanitize an error from a failed ACL operation into a safe diagnostic string. * The raw path must not appear in the returned string (it may contain @@ -254,6 +345,27 @@ function describeAclStateAfterTimeout(targetPath: string, deadline: number): str } } +async function describeAclStateAfterTimeoutAsync(targetPath: string, deadline: number): Promise { + try { + for (const sid of BROAD_SIDS) { + const remaining = deadline - nowFn(); + if (remaining <= 0) return "ACL state unverified (budget exhausted)"; + const found = await asyncIcaclsRunner([targetPath, "/findsid", sid], remaining); + if (!found.success) return "ACL state unverified (probe failed)"; + if (found.stdout.includes(targetPath)) return "broad ACL grants still present"; + } + return "no broad ACL grants detected (hardening still incomplete)"; + } catch { + return "ACL state unverified (probe failed)"; + } +} + +function timeoutMemoKey(targetPath: string, opts: HardenOptions): string { + // Destination-path memo only (issue #612). Never a parent directory — directory ACLs + // are not authoritative for newly created temps. + return opts.timeoutMemoKey ?? targetPath; +} + /** * Shared harden flow for files and directories: one total budget (env-configurable) * covering the initial attempt, ONE timeout retry, and the diagnostic verification. @@ -269,7 +381,8 @@ function hardenEntry( if (!existsSync(targetPath)) return { ok: true }; if (effectivePlatform() !== "win32") return { ok: true }; if (cache.has(targetPath)) return { ok: true }; - if (timedOutPaths.has(targetPath)) { + const memoKey = timeoutMemoKey(targetPath, opts); + if (timedOutPaths.has(memoKey)) { return { ok: false, diagnostics: "ACL hardening skipped — previous attempt timed out" }; } @@ -289,7 +402,7 @@ function hardenEntry( const diagnostics = sanitizeDiagnostics(lastErr); if (isTimeoutError(lastErr)) { - timedOutPaths.add(targetPath); + timedOutPaths.add(memoKey); const state = describeAclStateAfterTimeout(targetPath, deadline); const annotated = `${diagnostics}; ${state}`; // Timeout-only soft-fail: a hung icacls must not block OAuth/token writes. @@ -301,6 +414,47 @@ function hardenEntry( return { ok: false, diagnostics }; } +/** Async counterpart of hardenEntry — yields while waiting on icacls (#612). */ +async function hardenEntryAsync( + targetPath: string, + directory: boolean, + opts: HardenOptions, + cache: Set, +): Promise { + if (!existsSync(targetPath)) return { ok: true }; + if (effectivePlatform() !== "win32") return { ok: true }; + if (cache.has(targetPath)) return { ok: true }; + const memoKey = timeoutMemoKey(targetPath, opts); + if (timedOutPaths.has(memoKey)) { + return { ok: false, diagnostics: "ACL hardening skipped — previous attempt timed out" }; + } + + const deadline = nowFn() + resolveHardenDeadlineMs(); + let lastErr: unknown; + for (let attempt = 0; attempt < 2; attempt++) { + if (attempt > 0 && deadline - nowFn() <= 0) break; + try { + await runIcaclsAsync(targetPath, directory, deadline); + cache.add(targetPath); + return { ok: true }; + } catch (err) { + lastErr = err; + if (!isTimeoutError(err)) break; + } + } + + const diagnostics = sanitizeDiagnostics(lastErr); + if (isTimeoutError(lastErr)) { + timedOutPaths.add(memoKey); + const state = await describeAclStateAfterTimeoutAsync(targetPath, deadline); + const annotated = `${diagnostics}; ${state}`; + console.warn(`[opencodex] ${annotated} — continuing without NTFS ACL harden`); + return { ok: false, diagnostics: annotated }; + } + if (opts.required) throw new Error(diagnostics); + return { ok: false, diagnostics }; +} + /** * Harden a single file path with per-user NTFS ACLs on Windows. * On non-Windows platforms, returns ok:true immediately (caller owns chmod). @@ -312,6 +466,14 @@ export function hardenSecretPath(targetPath: string, opts: HardenOptions): Harde return hardenEntry(targetPath, false, opts, hardenedPaths); } +/** + * Async harden for write paths that must not block the event loop (#612). + * Same success/timeout/error policy as hardenSecretPath. + */ +export function hardenSecretPathAsync(targetPath: string, opts: HardenOptions): Promise { + return hardenEntryAsync(targetPath, false, opts, hardenedPaths); +} + /** * Harden a directory path with per-user NTFS ACLs on Windows. * On non-Windows platforms, returns ok:true immediately (caller owns chmod). @@ -322,3 +484,10 @@ export function hardenSecretPath(targetPath: string, opts: HardenOptions): Harde export function hardenSecretDir(targetPath: string, opts: HardenOptions): HardenResult { return hardenEntry(targetPath, true, opts, hardenedDirectories); } + +/** + * Async directory harden (#612). Same policy as hardenSecretDir. + */ +export function hardenSecretDirAsync(targetPath: string, opts: HardenOptions): Promise { + return hardenEntryAsync(targetPath, true, opts, hardenedDirectories); +} diff --git a/src/oauth/index.ts b/src/oauth/index.ts index c6dca4bc0..5ebaa320c 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -38,6 +38,7 @@ export { type OAuthHealthLabel, } from "./health"; export { OAUTH_REFRESH_LOCK_WAIT_MS, peekAuthStore, peekOAuthRefreshIntent } from "./store"; +import { codexAccountNamespaceProviderCollisionError } from "../codex/account-namespace-match"; const REFRESH_SKEW_MS = 60_000; export interface OAuthAccessSnapshot { @@ -60,6 +61,11 @@ function cached(p:string,a:string,c:OAuthCredentials,now:()=>number){const k=ver export interface LoginOpts { forceLogin?: boolean; /** When set, persist into this account slot and require matching identity. */ reauthAccountId?: string } +export interface LoginFlowLifecycle { + /** Runs after background credential/config persistence settles, before status becomes done. */ + onSettled?: () => void | Promise; +} + interface OAuthProviderDef { login(ctrl: OAuthController, opts?: LoginOpts): Promise; refresh( @@ -617,6 +623,8 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void { if (provider === "chatgpt") return; const def = OAUTH_PROVIDERS[provider]; if (!def) return; + const namespaceCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, provider); + if (namespaceCollision) throw new Error(namespaceCollision); const existing = config.providers[provider]; const next: OcxProviderConfig = { ...def.providerConfig }; if (existing && getProviderRegistryEntry(provider)?.allowKeyAuthOverride === true) { @@ -681,6 +689,16 @@ export async function runLogin( ): Promise { const def = OAUTH_PROVIDERS[provider]; if (!def) throw new UnsupportedOAuthProviderError(provider); + const loadLatestConfig = deps.loadConfig ?? loadConfig; + const saveLatestConfig = deps.saveConfig ?? saveConfig; + if (provider !== "chatgpt") { + const preflightConfig = loadLatestConfig(); + const namespaceCollision = codexAccountNamespaceProviderCollisionError( + preflightConfig.codexAccountNamespaces, + provider, + ); + if (namespaceCollision) throw new Error(namespaceCollision); + } // loginKiro keys its pending CLI-session transaction by object identity. Keep this exact object // for settlement even when source normalization below creates a derived credential object. const shouldRollbackKiroAccounts = provider === "kiro" && opts?.forceLogin === true; @@ -691,6 +709,12 @@ export async function runLogin( const cred: OAuthCredentials = rawCred.source ? rawCred : { ...rawCred, source: "oauth" }; const settleKiroTransaction = deps.settleKiroLoginTransaction ?? settleKiroLoginTransaction; try { + // Validate the provider row before credential persistence. A namespace claimed during the + // credential write is handled again below before the latest row is re-upserted. + if (provider !== "chatgpt") { + const preCommitConfig = loadLatestConfig(); + upsertOAuthProvider(preCommitConfig, provider); + } if (opts?.reauthAccountId) { const existing = getAccountCredential(provider, opts.reauthAccountId); if (!existing) throw new Error(`Unknown account for reauth: ${opts.reauthAccountId}`); @@ -712,9 +736,21 @@ export async function runLogin( }); } if (provider !== "chatgpt") { - const config = (deps.loadConfig ?? loadConfig)(); - upsertOAuthProvider(config, provider); - (deps.saveConfig ?? saveConfig)(config); + // Re-run against post-credential state so same-provider API-key additions, removals, + // and active-key switches survive. A late namespace claim wins over provider creation. + const latestConfig = loadLatestConfig(); + const lateCollision = codexAccountNamespaceProviderCollisionError( + latestConfig.codexAccountNamespaces, + provider, + ); + if (lateCollision) { + throw new Error( + `${lateCollision}. The credential for "${provider}" was saved, but the provider entry was not written. ` + + "Rename the account selector, then re-run the login.", + ); + } + upsertOAuthProvider(latestConfig, provider); + saveLatestConfig(latestConfig); } } catch (error) { const errors: unknown[] = [error]; @@ -894,7 +930,11 @@ export function cancelLoginFlow(provider: string): boolean { return true; } -export async function startLoginFlow(provider: string, opts?: LoginOpts): Promise<{ url: string; instructions?: string; deviceCode?: string }> { +export async function startLoginFlow( + provider: string, + opts?: LoginOpts, + lifecycle?: LoginFlowLifecycle, +): Promise<{ url: string; instructions?: string; deviceCode?: string }> { const def = OAUTH_PROVIDERS[provider]; if (!def) throw new UnsupportedOAuthProviderError(provider); const existing = loginState.get(provider); @@ -917,22 +957,44 @@ export async function startLoginFlow(provider: string, opts?: LoginOpts): Promis onManualCodeInput: (expectedState?: string) => waitForManualLoginCode(provider, abort.signal, expectedState), signal: abort.signal, }; - // Background: runLogin persists the credential + upserts the provider entry to disk config. - runLogin(provider, ctrl, opts) - .then(() => { + const settle = async (error?: unknown): Promise => { + let finalError = error; + try { + await lifecycle?.onSettled?.(); + } catch (settleError) { + // A successful credential/config commit is not fully live until its owner reconciles the + // runtime config. For an already-failed login, keep the original recovery error. + if (finalError === undefined) finalError = settleError; + } + if (finalError === undefined) { loginAbort.delete(provider); clearManualCodeSlot(provider); loginState.set(provider, { done: true }); // Local-token import (grok-cli / Claude Code keychain) completes WITHOUT firing onAuth — // resolve so the GUI call returns instead of hanging. if (!urlResolved) resolve({ url: "", instructions: "Logged in via an existing local CLI/keychain token — no browser needed." }); - }) - .catch((e: unknown) => { - loginAbort.delete(provider); - clearManualCodeSlot(provider); - const msg = e instanceof Error ? e.message : String(e); - loginState.set(provider, { done: true, error: msg }); - if (!urlResolved) reject(e); - }); + return; + } + + const e = finalError; + loginAbort.delete(provider); + clearManualCodeSlot(provider); + const msg = e instanceof Error ? e.message : String(e); + loginState.set(provider, { done: true, error: msg }); + if (!urlResolved) reject(e); + }; + // Background: runLogin persists the credential + provider entry to disk. The lifecycle hook + // lets a long-lived server config adopt that settled state before clients observe done=true. + void runLogin(provider, ctrl, opts).then( + () => settle(), + (e: unknown) => settle(e), + ).catch((e: unknown) => { + // settle catches lifecycle failures, so this is only a defensive promise-boundary guard. + loginAbort.delete(provider); + clearManualCodeSlot(provider); + const msg = e instanceof Error ? e.message : String(e); + loginState.set(provider, { done: true, error: msg }); + if (!urlResolved) reject(e); + }); }); } diff --git a/src/oauth/login-cli.ts b/src/oauth/login-cli.ts index cf225b9ce..d65288415 100644 --- a/src/oauth/login-cli.ts +++ b/src/oauth/login-cli.ts @@ -5,6 +5,7 @@ import { findLiveProxy, probeHostname } from "../server/proxy-liveness"; import { isPublicOAuthProvider, listOAuthProviders, runLogin } from "./index"; import { KEY_LOGIN_PROVIDERS, isKeyLoginProvider, validateApiKey, type KeyLoginProvider } from "./key-providers"; import type { OcxProviderConfig } from "../types"; +import { codexAccountNamespaceProviderCollisionError } from "../codex/account-namespace-match"; export function runningProxyUpdateHeaders(): Headers { const headers = new Headers({ "Content-Type": "application/json" }); @@ -106,6 +107,12 @@ export function providerConfigFromKeyLoginProvider(def: KeyLoginProvider, key: s async function handleKeyLogin(name: string): Promise { const def = KEY_LOGIN_PROVIDERS[name]; + const preflightConfig = loadConfig(); + const namespaceCollision = codexAccountNamespaceProviderCollisionError(preflightConfig.codexAccountNamespaces, name); + if (namespaceCollision) { + console.error(`Error: ${namespaceCollision}.`); + process.exit(1); + } console.log(`\n🔑 ${def.label} — opening ${def.dashboardUrl} so you can create/copy an API key...`); openUrl(def.dashboardUrl); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); @@ -135,6 +142,11 @@ async function handleKeyLogin(name: string): Promise { } const provider = providerConfigFromKeyLoginProvider(def, key, baseUrl); const config = loadConfig(); + const commitCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, name); + if (commitCollision) { + console.error(`Error: ${commitCollision}.`); + process.exit(1); + } config.providers[name] = provider; saveConfig(config); await notifyRunningProxy(name, provider); diff --git a/src/providers/alibaba-region-migration.ts b/src/providers/alibaba-region-migration.ts index 629d6fe56..8d488f246 100644 --- a/src/providers/alibaba-region-migration.ts +++ b/src/providers/alibaba-region-migration.ts @@ -2,6 +2,7 @@ import { ALIBABA_INTL_BASE_URL_CHOICES } from "./base-url-choices"; import { providerConfigSeed } from "./derive"; import { rewriteProviderReferences } from "./provider-id-rewrite"; import { PROVIDER_REGISTRY } from "./registry"; +import { codexAccountNamespaceProviderCollisionError } from "../codex/account-namespace-match"; import type { OcxConfig, OcxProviderConfig } from "../types"; const BEIJING_ID = "alibaba-token-plan"; @@ -91,9 +92,10 @@ function buildIntlRow(source: OcxProviderConfig): OcxProviderConfig { * those contracts across. That override fix shipped once, was backed out twice, * and was refused again when PR #459 closed. * - * Refuses to act when the destination already holds a value — a provider row or a - * key the reference rewrite would land on — because merging two settings is a - * user decision, not a migration's. + * Refuses to act when the destination is reserved by an account namespace or + * already holds a value — a provider row or a key the reference rewrite would + * land on — because choosing which setting wins is a user decision, not a + * migration's. */ export function projectAlibabaRegionMigration(config: OcxConfig): AlibabaRegionMigrationProjection { const beijing = config.providers[BEIJING_ID]; @@ -101,6 +103,17 @@ export function projectAlibabaRegionMigration(config: OcxConfig): AlibabaRegionM if (!beijing || !savedBaseUrl || !isInternationalEndpoint(savedBaseUrl)) { return { config, changed: false, warnings: [] }; } + if (codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, INTL_ID)) { + return { + config, + changed: false, + warnings: [ + `provider "${BEIJING_ID}" needs to move to "${INTL_ID}", but that destination is reserved ` + + `by a configured Codex account namespace. Nothing was changed. Rename the account ` + + `selector or move the provider manually, then restart.`, + ], + }; + } if (config.providers[INTL_ID]) { return { config, diff --git a/src/responses/state.ts b/src/responses/state.ts index c9201805b..3345e4626 100644 --- a/src/responses/state.ts +++ b/src/responses/state.ts @@ -1,6 +1,6 @@ import { chmodSync, existsSync, lstatSync, mkdirSync, opendirSync, readFileSync, unlinkSync } from "node:fs"; import { dirname, join } from "node:path"; -import { atomicWriteFile, getConfigDir } from "../config"; +import { atomicWriteFileAsync, getConfigDir } from "../config"; import type { OcxProviderContinuationState } from "../types"; const MAX_STORED_RESPONSES = 1_000; @@ -83,6 +83,8 @@ const replayedInputPrefixLengths = new WeakMap(); let loaded = false; let persistTimer: ReturnType | null = null; let pendingPersistPath: string | null = null; +/** Single-flight gate: overlapping response-state writes serialize (#612). */ +let persistGate: Promise = Promise.resolve(); function now(): number { return Date.now(); @@ -247,12 +249,18 @@ function ensureLoaded(): void { } } -function persistNow(path: string): void { +async function persistNow(path: string): Promise { if (persistTimer) { clearTimeout(persistTimer); persistTimer = null; } pendingPersistPath = null; + + // Serialize writers so concurrent flush + debounce cannot race on temps / ACL (#612). + const previous = persistGate; + let release!: () => void; + persistGate = new Promise(resolve => { release = resolve; }); + await previous; try { const entries: [string, StoredResponseState][] = []; let total = 0; @@ -273,9 +281,11 @@ function persistNow(path: string): void { // mkdirSync's mode only applies on creation — re-harden an existing config dir so the // conversation-content snapshot never lands in a group/world-readable directory. try { chmodSync(dirname(path), 0o700); } catch { /* best-effort (e.g. Windows) */ } - atomicWriteFile(path, JSON.stringify({ version: 2, states: entries })); + await atomicWriteFileAsync(path, JSON.stringify({ version: 2, states: entries })); } catch { /* best-effort: disk trouble must never affect request handling */ + } finally { + release(); } } @@ -285,15 +295,18 @@ function schedulePersist(): void { // debounce fires, and a late write must land in the home that owned the recorded state. pendingPersistPath = snapshotPath(); const path = pendingPersistPath; - persistTimer = setTimeout(() => persistNow(path), SNAPSHOT_DEBOUNCE_MS); + persistTimer = setTimeout(() => { void persistNow(path); }, SNAPSHOT_DEBOUNCE_MS); (persistTimer as { unref?: () => void }).unref?.(); } /** Flush any pending debounced snapshot write (graceful shutdown / deterministic tests). */ -export function flushResponseState(): void { - if (!persistTimer) return; - // Use the path captured when the write was scheduled — OPENCODEX_HOME may have moved since. - persistNow(pendingPersistPath ?? snapshotPath()); +export async function flushResponseState(): Promise { + if (persistTimer) { + await persistNow(pendingPersistPath ?? snapshotPath()); + return; + } + // No pending timer: still await any in-flight write so shutdown does not race (#612). + await persistGate; } function inputItems(input: unknown): unknown[] { @@ -448,6 +461,7 @@ export function clearResponseStateMemoryForTests(): void { clearTimeout(persistTimer); persistTimer = null; } + pendingPersistPath = null; states.clear(); storedResponseBytes = 0; loaded = false; diff --git a/src/router.ts b/src/router.ts index 6dc331f4d..b20ef7f12 100644 --- a/src/router.ts +++ b/src/router.ts @@ -293,7 +293,10 @@ function activeProviderEntries(config: OcxConfig): [string, OcxProviderConfig][] export class NoEnabledOpenAiProviderError extends Error { constructor(modelId: string) { - super(`No enabled OpenAI provider for model: ${modelId}. Run 'ocx init' to configure a provider, or check that your config has an enabled 'openai' provider.`); + super( + `Model ${modelId} requires the canonical openai provider. ` + + `Run: ocx provider add openai && ocx sync && ocx restart`, + ); this.name = "NoEnabledOpenAiProviderError"; } } diff --git a/src/server/lifecycle.ts b/src/server/lifecycle.ts index a07b493e4..cc063240d 100644 --- a/src/server/lifecycle.ts +++ b/src/server/lifecycle.ts @@ -86,7 +86,7 @@ export async function drainAndShutdown( } // Debounced replay-state snapshot may still be pending; flush so the last completed turn's // previous_response_id chain survives the restart this shutdown is usually part of. - flushResponseState(); + await flushResponseState(); // Tear down opt-in storage policy timers / worker / live-config sink so they cannot fire after stop. stopStorageCleanupScheduler(); abortStorageCleanupPolicyJob(); diff --git a/src/server/management/combo-routes.ts b/src/server/management/combo-routes.ts index f7942e87a..57c51e5bb 100644 --- a/src/server/management/combo-routes.ts +++ b/src/server/management/combo-routes.ts @@ -29,6 +29,10 @@ import { providerCodexAccountMode } from "../../providers/registry"; import { routedSlug, slugEquals } from "../../providers/slug-codec"; import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; +import { + CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR, + codexAccountNamespaceForModel, +} from "../../codex/account-namespace-match"; import { clearThreadAccountMap } from "../../codex/routing"; import { primeCodexPoolQuotas } from "../../codex/auth-api"; import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap"; @@ -123,6 +127,9 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise { const { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort } = ctx; @@ -86,6 +88,8 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< const body = await req.json().catch(() => ({})) as { provider?: string; addAccount?: boolean; accountId?: string; reauth?: boolean }; const provider = (body.provider ?? "").trim().toLowerCase(); if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400); + const namespaceCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, provider); + if (namespaceCollision) return jsonResponse({ error: namespaceCollision }, 409); const accountId = body.accountId?.trim(); const reauth = body.reauth === true || Boolean(accountId); try { @@ -96,12 +100,19 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< return jsonResponse({ error: "Unknown account for reauth" }, 404); } } + // Use persisted state, not the live object, as the merge base: another management + // request may already have mutated live config and yielded before its save. + const persistedBaseline = readConfigDiagnostics().config; // addAccount / reauth forces a fresh browser identity (skips local-CLI token import). const { url: authUrl, instructions, deviceCode } = await startLoginFlow(provider, { forceLogin: body.addAccount === true || reauth, ...(accountId ? { reauthAccountId: accountId } : {}), + }, { + // startLoginFlow returns the authorization URL before background persistence completes. + // Three-way reconcile settled disk changes so a failed login cannot leave a provider + // live-only and an in-flight management mutation cannot be erased before it saves. + onSettled: () => reconcileLiveConfigFromDisk(config, persistedBaseline), }); - upsertOAuthProvider(config, provider); // mutate LIVE config — routing sees it without restart if (authUrl && !deviceCode) { // Open the browser server-side (the proxy runs on the user's machine) — the GUI's // window.open is popup-blocked because it runs after an await, not a direct click. diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 53d17ff10..7735d22a4 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; import type { CatalogModel } from "../../codex/catalog"; import { catalogModelSlug, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; +import { providerModelsListFromProbeResponse } from "../../codex/catalog/provider-fetch"; import { DEFAULT_SUBAGENT_MODELS, codexAutoStartEnabled, @@ -29,6 +30,7 @@ import { providerCodexAccountMode } from "../../providers/registry"; import { routedSlug, slugEquals } from "../../providers/slug-codec"; import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; +import { codexAccountNamespaceProviderCollisionError } from "../../codex/account-namespace-match"; import { clearThreadAccountMap } from "../../codex/routing"; import { primeCodexPoolQuotas } from "../../codex/auth-api"; import { getProviderDiscoveryStatus } from "../../codex/model-cache"; @@ -99,6 +101,10 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise null) as { data?: unknown; models?: unknown } | null; - // OpenAI-style lists use { data: [...] }; Google's /v1beta/models (the other shape - // buildModelsRequest can produce) returns { models: [...] }. - const list = json && typeof json === "object" && !Array.isArray(json) - ? (Array.isArray(json.data) ? json.data : Array.isArray(json.models) ? json.models : undefined) - : undefined; + const json = await res.json().catch(() => null); + // OpenAI-style { data }, Google { models }, and Together-style top-level arrays (#617). + const list = providerModelsListFromProbeResponse(json); if (!Array.isArray(list)) { return jsonResponse({ ok: false, latencyMs, error: "upstream /models returned an unexpected shape" }); } diff --git a/src/server/proxy-liveness.ts b/src/server/proxy-liveness.ts index 67ff35667..0ed7e6bbf 100644 --- a/src/server/proxy-liveness.ts +++ b/src/server/proxy-liveness.ts @@ -37,6 +37,8 @@ export interface LiveProxy { port: number; /** Raw bind hostname the probe succeeded against; compose URLs via `probeHostname`. */ hostname?: string; + /** Whether the successful probe used runtime-port metadata or the configured listen port. */ + source: "runtime" | "config"; } /** @@ -118,7 +120,7 @@ export async function findLiveProxy(io: LivenessIo = {}): Promise { + const recordCompactPoolOutcome = ( + outcome: CodexUpstreamOutcome, + meta: { retryAfter?: string | null; resetAt?: unknown | unknown[] } = {}, + ) => { if (!usesCodexForwardPoolAuth(authCtx, route.provider)) return; recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, { ...meta, threadId: compactThreadId, + modelId: selectedModelId, probeLeaseId: codexProbeLeaseId(authCtx), + probeQuotaScope: codexProbeQuotaScope(authCtx), }); }; let upstream: Response; @@ -283,28 +289,30 @@ export async function handleResponsesCompact( { abortSignal: req.signal, label: safeHostLabel(compactUrl) }, ); } catch (err) { - if (req.signal.aborted) return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request"); + if (req.signal.aborted) { + recordCompactPoolOutcome(499); + return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request"); + } const outcome = err instanceof Error && err.name === "TimeoutError" ? "timeout" : "connect_error"; recordCompactPoolOutcome(outcome); return formatErrorResponse(502, "upstream_error", "Failed to connect to compact upstream"); } const retryAfter = upstream.headers.get("retry-after"); + const resetAt = [ + upstream.headers.get("x-codex-primary-reset-at"), + upstream.headers.get("x-codex-secondary-reset-at"), + upstream.headers.get("x-codex-tertiary-reset-at"), + ].filter(Boolean); const buffered = await bufferCompactResponse(upstream, req.signal); // Record pool health only after the body is fully delivered (or definitively failed). // A premature 200 would clear soft-avoid while the client still sees a buffer 502. if (buffered.status === 499) { + recordCompactPoolOutcome(499); return buffered; } - if (upstream.ok && buffered.status >= 500) { - // The upstream account returned 200 — it is healthy. The buffering failure - // (oversized body exceeding COMPACT_RESPONSE_MAX_BYTES, or a rare mid-read - // reset on a small JSON payload) is a local proxy issue, not account flakiness. - // Record the upstream status so a deterministic payload-size limit does not - // soft-avoid a healthy account and rotate a thread for 30s. - recordCompactPoolOutcome(upstream.status, { retryAfter }); - } else { - recordCompactPoolOutcome(upstream.status, { retryAfter }); - } + // Always record the real upstream status: a local buffering failure after a + // 200 upstream response must not soft-avoid a healthy account or rotate a thread. + recordCompactPoolOutcome(upstream.status, { retryAfter, resetAt }); return buffered; } diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 95b56dc62..74ab53725 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -52,7 +52,7 @@ import { rotateAnthropicAccountOn429, } from "../../oauth/anthropic-routing"; import { buildWebSearchTool, planWebSearch, runWithWebSearch, shouldResolveOpenAiWebSearchSidecar } from "../../web-search"; -import { buildImageTool, planImageBridge, runWithImageBridge, clampImageMaxRounds, IMAGE_GEN_TOOL_NAME } from "../../images"; +import { buildImageTool, buildVideoTool, planImageBridge, planVideoBridge, runWithImageBridge, clampImageMaxRounds, IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME } from "../../images"; import { describeImagesInPlace, planVisionSidecar, shouldResolveOpenAiVisionSidecar, stripImagesInPlace } from "../../vision"; import { createAdapterEventQueue, preflightAdapterEvents } from "../../adapters/run-turn-queue"; import { @@ -67,6 +67,7 @@ import { isCodexAuthContextUsable, resolveCodexAuthContext, codexProbeLeaseId, + codexProbeQuotaScope, releaseCodexAuthContextProbeLease, stripCodexRuntimeProviderFields, type CodexAuthContext, @@ -320,7 +321,7 @@ async function retryCodexPoolOnAlternateAccount( req.headers, config, "pool", - { excludeAccountId: firstAuthCtx.accountId }, + { excludeAccountId: firstAuthCtx.accountId, modelId: route.modelId }, ); } catch (error) { if ( @@ -342,7 +343,9 @@ async function retryCodexPoolOnAlternateAccount( recordCodexUpstreamOutcome(config, firstAuthCtx.accountId, outcomeStatus, { ...quotaMeta, threadId: req.headers.get("x-codex-parent-thread-id"), + modelId: route.modelId, probeLeaseId: codexProbeLeaseId(firstAuthCtx), + probeQuotaScope: codexProbeQuotaScope(firstAuthCtx), // Retry already advanced the RR ring via excludeAccountId — reuse for promotion. ...(retryAuthCtx.accountId ? { promoteAccountId: retryAuthCtx.accountId } : {}), }); @@ -403,6 +406,7 @@ export function codexForwardTerminalOutcomeRecorder( config: OcxConfig, authCtx: CodexAuthContext, provider: OcxProviderConfig, + modelId?: string, logCtx?: RequestLogContext, threadId?: string | null, ): ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined { @@ -414,7 +418,9 @@ export function codexForwardTerminalOutcomeRecorder( // prior soft-avoid so a healthy account isn't stuck avoided. recordCodexUpstreamOutcome(config, authCtx.accountId, 200, { threadId, + modelId, probeLeaseId: codexProbeLeaseId(authCtx), + probeQuotaScope: codexProbeQuotaScope(authCtx), }); return; } @@ -432,7 +438,9 @@ export function codexForwardTerminalOutcomeRecorder( : (httpStatusOverride ?? logCtx?.terminalHttpStatus ?? 502); recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, { threadId, + modelId, probeLeaseId: codexProbeLeaseId(authCtx), + probeQuotaScope: codexProbeQuotaScope(authCtx), }); }; } @@ -668,7 +676,7 @@ async function resolveResponsesCodexAuth( if (route.codexAccountMode === "direct") validateForwardAdmissionCredential(req.headers, config); let authCtx: CodexAuthContext; if (route.codexAccountMode) { - authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode); + authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode, { modelId: route.modelId }); options.onCodexAuthContextResolved?.(authCtx); } else { authCtx = { kind: "main", accountId: null }; @@ -1428,7 +1436,9 @@ export async function handleResponses( if (usesCodexForwardPoolAuth(authCtx, route.provider)) { recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, { threadId: req.headers.get("x-codex-parent-thread-id"), + modelId: route.modelId, probeLeaseId: codexProbeLeaseId(authCtx), + probeQuotaScope: codexProbeQuotaScope(authCtx), }); } const msg = outcome === "timeout" @@ -1514,6 +1524,7 @@ export async function handleResponses( config, authCtx, route.provider, + route.modelId, logCtx, req.headers.get("x-codex-parent-thread-id"), ); @@ -1553,7 +1564,9 @@ export async function handleResponses( recordCodexUpstreamOutcome(config, authCtx.accountId, upstreamResponse.status, { ...quotaMeta, threadId: req.headers.get("x-codex-parent-thread-id"), + modelId: route.modelId, probeLeaseId: codexProbeLeaseId(authCtx), + probeQuotaScope: codexProbeQuotaScope(authCtx), }); } } @@ -1756,48 +1769,74 @@ export async function handleResponses( ? planWebSearch(config, parsed, false, route.provider, route.modelId, openAiSidecar) : undefined; const imgPlan = !routedCompaction ? await planImageBridge(config, parsed, route.provider) : undefined; + const vidPlan = !routedCompaction ? await planVideoBridge(config, parsed, route.provider) : undefined; const canRunWebSearch = !!wsPlan && !adapter.runTurn; - if (imgPlan && (!wsPlan || adapter.runTurn)) { - // The bridge forces stream:true internally and returns SSE. Non-streaming requests can't be - // served — reject explicitly rather than returning SSE to a client expecting JSON. + if ((imgPlan || vidPlan) && canRunWebSearch) { + // Web search takes priority when both are active — the media bridge cannot run + // alongside runWithWebSearch. Surface a runtime signal so the user knows their + // configured video/image bridge was skipped for this turn, rather than silently + // dropping a paid capability. + if (vidPlan) console.warn("[videos] video bridge skipped: web search is active for this turn"); + if (imgPlan) console.warn("[images] image bridge skipped: web search is active for this turn"); + } + if ((imgPlan || vidPlan) && (!wsPlan || adapter.runTurn)) { + // The image bridge detects a hosted image_generation tool and requires streaming. + // The video bridge activates from config and injects a tool — it also needs streaming + // (the loop returns SSE). For video-only (no imgPlan) on a non-streaming request, skip + // the bridge entirely so enabling the feature doesn't break ordinary non-streaming traffic. if (!parsed.stream) { - return formatErrorResponse(400, "invalid_request_error", "image bridge requires stream=true"); - } - // Replace any pre-existing image_gen alias instead of appending a duplicate wire name. + if (imgPlan) { + return formatErrorResponse(400, "invalid_request_error", "image bridge requires stream=true"); + } + // Video-only: skip bridge for non-streaming requests + } else { + // Replace any pre-existing image_gen/video_gen aliases instead of appending duplicate wire names. const priorTools = parsed.context.tools ?? []; - parsed.context.tools = [ - ...priorTools.filter(t => { - if (t.imageGeneration) return false; - if (imgPlan.toolNames.has(t.name)) return false; - if (t.namespace && imgPlan.toolNames.has(namespacedToolName(t.namespace, t.name))) return false; - return true; - }), - buildImageTool(), - ]; + const bridgeTools = [...priorTools.filter(t => { + if (t.imageGeneration) return false; + if (t.videoGeneration) return false; + if (imgPlan && imgPlan.toolNames.has(t.name)) return false; + if (imgPlan && t.namespace && imgPlan.toolNames.has(namespacedToolName(t.namespace, t.name))) return false; + // Only strip unnamespaced video_gen aliases — a namespaced MCP video_gen is left alone. + if (vidPlan && !t.namespace && vidPlan.toolNames.has(t.name)) return false; + return true; + })]; + const existingNames = new Set(bridgeTools.map(t => t.name)); + if (imgPlan && !existingNames.has(IMAGE_GEN_TOOL_NAME)) bridgeTools.push(buildImageTool()); + if (vidPlan && !existingNames.has(VIDEO_GEN_TOOL_NAME)) bridgeTools.push(buildVideoTool()); + parsed.context.tools = bridgeTools; // Hosted image_generation tool_choice / allowed_tools must target the synthetic function name. + // Gate on imgPlan — in a video-only turn buildImageTool() was never injected, so rewriting + // image_generation/image_gen aliases would add an undeclared tool that strict upstreams reject. const tc = parsed.options.toolChoice; - if (tc && typeof tc === "object" && "allowedTools" in tc && Array.isArray(tc.allowedTools)) { + if (imgPlan && tc && typeof tc === "object" && "allowedTools" in tc && Array.isArray(tc.allowedTools)) { const mapped = tc.allowedTools.map(name => - name === "image_generation" || name === "image_gen" || imgPlan.toolNames.has(name) + name === "image_generation" || name === "image_gen" || (imgPlan.toolNames.has(name) ?? false) ? IMAGE_GEN_TOOL_NAME : name, ); parsed.options.toolChoice = { ...tc, allowedTools: [...new Set(mapped)] }; - } else if (tc && typeof tc === "object" && "name" in tc && typeof tc.name === "string" + } else if (imgPlan && tc && typeof tc === "object" && "name" in tc && typeof tc.name === "string" && (tc.name === "image_generation" || imgPlan.toolNames.has(tc.name))) { parsed.options.toolChoice = { ...tc, name: IMAGE_GEN_TOOL_NAME }; } const imgResponse = await runWithImageBridge({ parsed, adapter, - plan: imgPlan, + ...(imgPlan ? { plan: imgPlan } : {}), + ...(vidPlan ? { videoPlan: vidPlan } : {}), forwardHeaders: selectedForwardHeaders, onAttemptSend: () => noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens), abortSignal: options.abortSignal, - maxRounds: clampImageMaxRounds(config.images?.maxRounds), + maxRounds: imgPlan && vidPlan + ? clampImageMaxRounds(Math.min(config.images?.maxRounds ?? 3, config.images?.videoMaxRounds ?? 2)) + : imgPlan + ? clampImageMaxRounds(config.images?.maxRounds) + : clampImageMaxRounds(config.images?.videoMaxRounds ?? 2), connectTimeoutMs: config.connectTimeoutMs ?? 200_000, stallTimeoutSec: config.stallTimeoutSec, fetchImpl: providerFetch(route.provider), onRequestBuilt: request => recordAdapterReasoning(logCtx, request), + ...(vidPlan?.timeoutMs ? { videoTimeoutMs: vidPlan.timeoutMs } : {}), onUsage: usage => { // Cursor may assign _cursorConversationId inside the image loop's first runTurn; // backfill so Logs can filter/total that opening request (parity with the normal @@ -1843,6 +1882,7 @@ export async function handleResponses( }); } return imgResponse; + } // end else (streaming bridge) } // Web-search sidecar: Codex enabled web_search but this is a routed (non-OpenAI) model that can't diff --git a/src/types.ts b/src/types.ts index 78d2e706f..12f5f1722 100644 --- a/src/types.ts +++ b/src/types.ts @@ -160,6 +160,8 @@ export interface OcxTool { webSearch?: boolean; /** Synthetic image_gen tool: the model's call is executed by the xAI image bridge sidecar, not relayed to Codex. */ imageGeneration?: boolean; + /** Synthetic video_gen tool: executed by the xAI video bridge sidecar. */ + videoGeneration?: boolean; } /** @@ -678,6 +680,14 @@ export interface OcxConfig { search?: OcxSearchConfig; /** Codex multi-account pool. */ codexAccounts?: CodexAccount[]; + /** Account ids administratively excluded from future pool selection until resumed. */ + pausedCodexAccountIds?: string[]; + /** + * Public model-selector namespaces bound to one Codex account. Values are stored account ids; + * `"@main"` selects the Codex Desktop/main auth.json account. Account display aliases + * are intentionally separate from these selectors. + */ + codexAccountNamespaces?: Record; /** Active pool account id for next session. undefined = main (passthrough as-is). */ activeCodexAccountId?: string; /** Auto-switch threshold (0-100). Default 80. 0 = disabled. */ @@ -782,6 +792,14 @@ export interface OcxImagesConfig { maxRounds?: number; /** Max files retained under artifacts/. Oldest deleted when exceeded. Default 200. */ artifactsKeepCount?: number; + /** Master switch for the video bridge. Default false — must be explicitly opted in. */ + videoBridgeEnabled?: boolean; + /** Model for xAI video generation. Default "grok-imagine-video". */ + videoBridgeModel?: string; + /** Max video-gen rounds before forced-final. Default 2 (video is slower than image). */ + videoMaxRounds?: number; + /** Per-video generation timeout (ms) including polling. Default 300000 (5 min). */ + videoTimeoutMs?: number; } export interface OcxSearchConfig { diff --git a/structure/08_openai-provider-tiers.md b/structure/08_openai-provider-tiers.md index a0da13487..8a0484d6c 100644 --- a/structure/08_openai-provider-tiers.md +++ b/structure/08_openai-provider-tiers.md @@ -18,6 +18,18 @@ engine. Direct short-circuits that engine before pool state is read or mutated a current caller/main-login bearer. Neither mode may fall through to `openai-apikey`, and the API provider may not fall through to Codex-login credentials. +An explicit `Retry-After` or an unclassified quota 429 is account-wide. A reset-derived native-model +429 is advisory and remains within its confirmed quota group: `gpt-5.3-codex-spark` is separate from +the shared native group (including GPT-5.6 Terra/Luna). This allows a same-account combo to test an +independent quota without allowing fallbacks that share the exhausted quota. + +`pausedCodexAccountIds` is a persisted Pool eligibility boundary. A paused added account or the +stable `__main__` alias remains visible for maintenance and quota reads, but is excluded from new +affinity, quota rotation, cooldown probes, transient failover, and manual activation. In-flight +requests keep their captured credential. An all-paused pool fails closed. +The dashboard's bulk pause action refreshes all account quotas and mutates only accounts whose +plan-relevant window is freshly confirmed at exactly 100%; unknown and failed refreshes are skipped. + ```text gpt-5.6-sol # openai; Pool or Direct follows the provider option openai-apikey/gpt-5.6-sol # OpenAI API key diff --git a/tests/alibaba-region-migration.test.ts b/tests/alibaba-region-migration.test.ts index 54db6fae9..3b56fb8be 100644 --- a/tests/alibaba-region-migration.test.ts +++ b/tests/alibaba-region-migration.test.ts @@ -20,6 +20,13 @@ function migratableConfig(): OcxConfig { } as unknown as OcxConfig; } +function namespaceCollidingConfig(): OcxConfig { + return { + ...migratableConfig(), + codexAccountNamespaces: { "alibaba-token-plan-intl": "pool-a" }, + }; +} + test("moves a Beijing entry holding an international endpoint", () => { const config = migratableConfig(); // Beijing catalog fields, as `ocx provider add` would have persisted them. @@ -94,6 +101,51 @@ test("refuses to merge when the intl entry exists, and says why", () => { expect(projection.warnings[0]).toContain("already exists"); }); +test("refuses to replace an account namespace with the intl provider", () => { + const config = namespaceCollidingConfig(); + const before = structuredClone(config); + + const projection = projectAlibabaRegionMigration(config); + expect(projection.changed).toBe(false); + expect(projection.config).toEqual(before); + expect(projection.warnings).toHaveLength(1); + expect(projection.warnings[0]).toContain("reserved by a configured Codex account namespace"); + expect(projection.warnings[0]).toContain("Rename the account selector"); +}); + +test("refuses a mixed-case account namespace that owns the intl provider id", () => { + const config = migratableConfig(); + config.codexAccountNamespaces = { "ALIBABA-TOKEN-PLAN-INTL": "pool-a" }; + const before = structuredClone(config); + + const projection = projectAlibabaRegionMigration(config); + + expect(projection.changed).toBe(false); + expect(projection.config).toEqual(before); + expect(projection.warnings).toHaveLength(1); + expect(projection.warnings[0]).toContain("reserved by a configured Codex account namespace"); +}); + +test("a namespace-blocked migration remains valid across reload", () => { + const projection = projectAlibabaRegionMigration(namespaceCollidingConfig()); + expect(projection.changed).toBe(false); + + const home = mkdtempSync(join(tmpdir(), "ocx-alibaba-namespace-")); + const prev = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = home; + try { + saveConfig(projection.config); + const reloaded = loadConfig(); + expect(reloaded.providers["alibaba-token-plan"]).toBeDefined(); + expect(reloaded.providers["alibaba-token-plan-intl"]).toBeUndefined(); + expect(reloaded.codexAccountNamespaces).toEqual({ "alibaba-token-plan-intl": "pool-a" }); + } finally { + if (prev === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = prev; + rmSync(home, { recursive: true, force: true }); + } +}); + test("aborts without changing anything when a destination key is occupied", () => { const config = migratableConfig(); config.providerContextCaps = { "alibaba-token-plan": 500_000, "alibaba-token-plan-intl": 900_000 }; diff --git a/tests/alibaba-region-startup.test.ts b/tests/alibaba-region-startup.test.ts index e366c0d92..eda003d50 100644 --- a/tests/alibaba-region-startup.test.ts +++ b/tests/alibaba-region-startup.test.ts @@ -22,6 +22,13 @@ function collidingConfig(): OcxConfig { return config; } +function namespaceCollidingConfig(): OcxConfig { + return { + ...migratableConfig(), + codexAccountNamespaces: { "alibaba-token-plan-intl": "pool-a" }, + }; +} + test("backs up strictly before saving, exactly once, when the projection changed", () => { const order: string[] = []; const saved: OcxConfig[] = []; @@ -57,6 +64,27 @@ test("a no-op never backs up or saves, but a collision still warns", () => { expect(warnings[0]).toContain("[alibaba-region-migration]"); }); +test("an account namespace collision warns without backing up or saving", () => { + const order: string[] = []; + const warnings: string[] = []; + const originalWarn = console.warn; + console.warn = (...args: unknown[]) => { warnings.push(args.map(String).join(" ")); }; + try { + const config = namespaceCollidingConfig(); + const result = runAlibabaRegionStartupMigration(config, { + project: projectAlibabaRegionMigration, + backup: () => { order.push("backup"); }, + save: () => { order.push("save"); }, + }); + expect(result).toBe(config); + } finally { + console.warn = originalWarn; + } + expect(order).toEqual([]); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("reserved by a configured Codex account namespace"); +}); + test("a backup failure prevents the migration from saving", () => { // The fail-closed posture: no rollback point, no credential rewrite. The throw // propagates out of startServer, the same stance the OpenAI tier migration takes. diff --git a/tests/ci-workflows.test.ts b/tests/ci-workflows.test.ts index ec95bc200..644f29c42 100644 --- a/tests/ci-workflows.test.ts +++ b/tests/ci-workflows.test.ts @@ -5,8 +5,17 @@ import { callsTo, methodsOf, runEnforcePrTarget, + type HarnessResult, } from "./helpers/enforce-pr-target-harness"; +/** Final enforcer comment body after pending/draft checkpoints. */ +function lastEnforcerCommentBody(result: HarnessResult): string { + const updates = callsTo(result, "issues.updateComment") as Array<{ body: string }>; + if (updates.length > 0) return updates[updates.length - 1]!.body; + const creates = callsTo(result, "issues.createComment") as Array<{ body: string }>; + return creates[creates.length - 1]!.body; +} + const root = new URL("../", import.meta.url); const doctorGuiIfChangedScript = fileURLToPath(new URL("../scripts/doctor-gui-if-changed.ts", import.meta.url)); @@ -466,10 +475,15 @@ describe("GitHub Actions hardening", () => { // single assertion. expect(Object.keys(workflow.on?.pull_request_target ?? {})).toEqual(["types"]); - // Exactly one permission scope. Asserting that `pull-requests: write` is - // present says nothing about what was added beside it, and a `write-all` - // scalar is not an object at all. - expect(workflow.permissions).toEqual({ "pull-requests": "write" }); + // Exactly the scopes this gate needs. `pull-requests: write` covers title + // and comment updates. `contents: write` is required for the draft GraphQL + // mutations with GITHUB_TOKEN (#626: "Resource not accessible by integration" + // when contents was unset). Asserting the whole object pins both presence + // and the absence of anything broader (write-all, contents alone, …). + expect(workflow.permissions).toEqual({ + contents: "write", + "pull-requests": "write", + }); // One run per PR, so two rapid events cannot race on the title/draft state, // and no `cancel-in-progress` — cancelling the in-flight run mid-mutation is @@ -738,9 +752,11 @@ describe("GitHub Actions hardening", () => { "issues.createComment", "pulls.update", "graphql", + "issues.updateComment", + "issues.updateComment", ]); - const [comment] = callsTo(result, "issues.createComment") as [{ body: string }]; - expect(comment.body).toContain(`\`${ref}\``); + expect(lastEnforcerCommentBody(result)).toContain(`\`${ref}\``); + expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe(true); } }); @@ -787,14 +803,16 @@ describe("GitHub Actions hardening", () => { "issues.updateComment", "pulls.update", "graphql", + "issues.updateComment", + "issues.updateComment", ]); expect(callsTo(result, "pulls.update")).toEqual([ { owner: "lidge-jun", repo: "opencodex", pull_number: 42, title: "[WRONG BRANCH] Port the runtime entry" }, ]); - const [comment] = callsTo(result, "issues.updateComment") as [{ body: string }]; - expect(comment.body).toContain('"active":true'); - expect(comment.body).toContain('"autoDraftedByBot":true'); - expect(comment.body).toContain('"titlePrefixedByBot":true'); + expect(lastEnforcerCommentBody(result)).toContain('"active":true'); + expect(lastEnforcerCommentBody(result)).toContain('"autoDraftedByBot":true'); + expect(lastEnforcerCommentBody(result)).toContain('"titlePrefixedByBot":true'); + expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe(true); }); test("the wrong-target explanation lists every allowed base and names the default", async () => { @@ -805,11 +823,11 @@ describe("GitHub Actions hardening", () => { const result = await run({ pr: { base: { ref: "main" }, title: "Add a thing", draft: false }, }); - const [comment] = callsTo(result, "issues.createComment") as [{ body: string }]; + const commentBody = lastEnforcerCommentBody(result); - expect(comment.body).toContain("must target one of `dev` or `dev2-go`"); - expect(comment.body).toContain("Please retarget this PR to `dev`"); - expect(comment.body).toContain("only for scoped Go native-port work"); + expect(commentBody).toContain("must target one of `dev` or `dev2-go`"); + expect(commentBody).toContain("Please retarget this PR to `dev`"); + expect(commentBody).toContain("only for scoped Go native-port work"); }); test("a PR targeting main is prefixed, drafted, and explained — and nothing else", async () => { @@ -817,14 +835,17 @@ describe("GitHub Actions hardening", () => { pr: { base: { ref: "main" }, title: "Add a thing", draft: false }, }); - // The comment lands before the mutations, so a rerun after a failed - // mutation can still find the restoration state. + // Pending ownership comment first, then title/draft, then checkpoints so + // a mid-run crash still records ownership and autoDraftedByBot only after + // convertToDraft succeeds (#626 / Codex review on #631). expect(methodsOf(result)).toEqual([ "pulls.get", "issues.listComments", "issues.createComment", "pulls.update", "graphql", + "issues.updateComment", + "issues.updateComment", ]); // The title update carries the title and nothing else. `base`, `state` @@ -834,17 +855,22 @@ describe("GitHub Actions hardening", () => { { owner: "lidge-jun", repo: "opencodex", pull_number: 42, title: "[WRONG BRANCH] Add a thing" }, ]); - // The comment addresses this PR, by its own number. - const [comment] = callsTo(result, "issues.createComment") as [{ issue_number: number; body: string }]; - expect(comment.issue_number).toBe(42); - expect(comment.body).toContain(MARKER); - expect(comment.body).toContain("@contributor"); + // The first comment create addresses this PR, by its own number. + const [created] = callsTo(result, "issues.createComment") as [{ issue_number: number; body: string }]; + expect(created.issue_number).toBe(42); + expect(created.body).toContain(MARKER); + const commentBody = lastEnforcerCommentBody(result); + expect(commentBody).toContain("@contributor"); + expect(commentBody).toContain('"autoDraftedByBot":true'); // The only GraphQL mutation is the draft conversion — not a retarget. const [draft] = callsTo(result, "graphql") as [{ query: string; variables: unknown }]; expect(draft.query).toContain("convertPullRequestToDraft"); expect(draft.query).not.toContain("updatePullRequest"); expect(draft.variables).toEqual({ pullRequestId: "PR_kwDOnode42" }); + + // Wrong-base runs must fail the required check even when mutations succeed. + expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe(true); }); test("a PR that was already a draft is not un-drafted afterwards", async () => { @@ -852,16 +878,18 @@ describe("GitHub Actions hardening", () => { pr: { base: { ref: "main" }, draft: true }, }); - // No draft conversion: it is already a draft. And the state it records - // says so, which is what stops the restore path from marking it ready. + // No draft conversion: it is already a draft. Pending ownership first, + // then title prefix, then final explanation. State records that the bot + // did not draft — which stops restore from marking it ready. expect(methodsOf(wrong)).toEqual([ "pulls.get", "issues.listComments", "issues.createComment", "pulls.update", + "issues.updateComment", ]); - const [comment] = callsTo(wrong, "issues.createComment") as [{ body: string }]; - expect(comment.body).toContain('"autoDraftedByBot":false'); + expect(lastEnforcerCommentBody(wrong)).toContain('"autoDraftedByBot":false'); + expect(wrong.warnings.some((w) => w.startsWith("setFailed:"))).toBe(true); // Now retarget it correctly, feeding that state back in. const restored = await run({ @@ -925,8 +953,13 @@ describe("GitHub Actions hardening", () => { comments: [botComment({ version: 1, active: true, autoDraftedByBot: true, titlePrefixedByBot: true })], }); - // The comment is refreshed; the title and draft state are already right. - expect(methodsOf(result)).toEqual(["pulls.get", "issues.listComments", "issues.updateComment"]); + // The comment is refreshed (pending + final); title and draft are already right. + expect(methodsOf(result)).toEqual([ + "pulls.get", + "issues.listComments", + "issues.updateComment", + "issues.updateComment", + ]); }); test("the verdict comes from the fetched PR, not the stale event payload", async () => { @@ -948,6 +981,8 @@ describe("GitHub Actions hardening", () => { "issues.createComment", "pulls.update", "graphql", + "issues.updateComment", + "issues.updateComment", ]); expect(callsTo(wentWrong, "pulls.update")).toEqual([ { owner: "lidge-jun", repo: "opencodex", pull_number: 42, title: "[WRONG BRANCH] Add a thing" }, @@ -972,9 +1007,8 @@ describe("GitHub Actions hardening", () => { pr: { base: { ref: "main" }, title: "Add a thing", draft: false }, eventPayload: { base: { ref: "dev" }, title: "Add a thing", draft: false }, }); - const [posted] = callsTo(wrongTarget, "issues.createComment") as [{ body: string }]; - expect(posted.body).toContain("currently targets `main`"); - expect(posted.body).not.toContain("currently targets `dev`"); + expect(lastEnforcerCommentBody(wrongTarget)).toContain("currently targets `main`"); + expect(lastEnforcerCommentBody(wrongTarget)).not.toContain("currently targets `dev`"); // The corrected-path sentence: the event still carries the old wrong // base, the live PR is on dev2-go. Naming the event's base here tells the @@ -1043,6 +1077,8 @@ describe("GitHub Actions hardening", () => { "issues.updateComment", "pulls.update", "graphql", + "issues.updateComment", + "issues.updateComment", ]); expect(result.warnings.join(" ")).toContain("Could not parse stored workflow state"); }); @@ -1069,6 +1105,7 @@ describe("GitHub Actions hardening", () => { "pulls.get", "issues.listComments", "issues.updateComment", + "issues.updateComment", ]); // Corrected: nothing to undo, but the state must still be cleared or the @@ -1137,20 +1174,20 @@ describe("GitHub Actions hardening", () => { pr: { base: { ref: "main" }, title: "Add a thing", draft: false, user: { login: "someone-else" } }, }); - const [comment] = callsTo(result, "issues.createComment") as [{ body: string }]; + const commentBody = lastEnforcerCommentBody(result); // Addressed to the PR author, so GitHub actually notifies them. - expect(comment.body).toContain("@someone-else"); + expect(commentBody).toContain("@someone-else"); // Names every branch involved, so the instruction is actionable without // context: where the PR is now, where it should go, and the one other // base that is legitimate but not the default. - expect(comment.body).toContain("`main`"); - expect(comment.body).toContain("`dev`"); - expect(comment.body).toContain("`dev2-go`"); + expect(commentBody).toContain("`main`"); + expect(commentBody).toContain("`dev`"); + expect(commentBody).toContain("`dev2-go`"); // Points at the documentation rather than assuming the reader knows. - expect(comment.body).toContain("https://lidge-jun.github.io/opencodex/contributing/"); + expect(commentBody).toContain("https://lidge-jun.github.io/opencodex/contributing/"); // And carries the state the next run needs. - expect(comment.body).toContain(MARKER); - expect(comment.body).toContain('"version":1'); + expect(commentBody).toContain(MARKER); + expect(commentBody).toContain('"version":1'); }); test("comment listing asks for full pages, so the bot's own comment is found", async () => { @@ -1230,10 +1267,12 @@ describe("GitHub Actions hardening", () => { "issues.updateComment", "pulls.update", "graphql", + "issues.updateComment", + "issues.updateComment", ]); - const [refreshed] = callsTo(wrong, "issues.updateComment") as [{ body: string }]; - expect(refreshed.body).toContain(`"version":${version}`); - expect(refreshed.body).toContain('"active":true'); + expect(lastEnforcerCommentBody(wrong)).toContain(`"version":${version}`); + expect(lastEnforcerCommentBody(wrong)).toContain('"active":true'); + expect(wrong.warnings.some((w) => w.startsWith("setFailed:"))).toBe(true); } }); @@ -1280,19 +1319,21 @@ describe("GitHub Actions hardening", () => { expect(cleared.body).toContain('"active":false'); }); - test("the state comment is written before the PR is touched", async () => { - // Order is the recovery story. The comment records what the workflow is - // about to change; if a mutation fails afterwards, a rerun reads that - // record and finishes the job. Write the PR first and a failure in - // between leaves a renamed, drafted PR with no record that the bot did - // it — permanently stuck. The scenario above asserts the full call list, - // but this states the invariant on its own so a reordering says why. + test("ownership comment is checkpointed before mutations and finalized after", async () => { + // Pending ownership is written before title/draft so a crash mid-mutation + // still records what the bot owns. autoDraftedByBot is only true in the + // final body after convertToDraft succeeds (#626 / #631). const result = await run({ pr: { base: { ref: "main" }, draft: false } }); const methods = methodsOf(result); - const comment = methods.indexOf("issues.createComment"); - expect(comment).toBeGreaterThan(-1); - expect(comment).toBeLessThan(methods.indexOf("pulls.update")); - expect(comment).toBeLessThan(methods.indexOf("graphql")); + const pending = methods.indexOf("issues.createComment"); + const title = methods.indexOf("pulls.update"); + const draft = methods.indexOf("graphql"); + const finalUpdate = methods.lastIndexOf("issues.updateComment"); + expect(pending).toBeGreaterThan(-1); + expect(pending).toBeLessThan(title); + expect(title).toBeLessThan(draft); + expect(draft).toBeLessThan(finalUpdate); + expect(lastEnforcerCommentBody(result)).toContain('"autoDraftedByBot":true'); }); test("a title that is exactly the prefix is still enforced", async () => { @@ -1305,18 +1346,18 @@ describe("GitHub Actions hardening", () => { pr: { base: { ref: "main" }, title: "[WRONG BRANCH] ", draft: false }, }); - // Already prefixed, so no title write — but the state and the draft - // conversion still have to happen. + // Already prefixed, so no title write — but pending/draft/final still run. expect(callsTo(result, "pulls.update")).toEqual([]); expect(methodsOf(result)).toEqual([ "pulls.get", "issues.listComments", "issues.createComment", "graphql", + "issues.updateComment", + "issues.updateComment", ]); - const [comment] = callsTo(result, "issues.createComment") as [{ body: string }]; - expect(comment.body).toContain('"titlePrefixedByBot":false'); - expect(comment.body).toContain('"autoDraftedByBot":true'); + expect(lastEnforcerCommentBody(result)).toContain('"titlePrefixedByBot":false'); + expect(lastEnforcerCommentBody(result)).toContain('"autoDraftedByBot":true'); }); test("an empty title is enforced rather than skipped", async () => { @@ -1335,6 +1376,7 @@ describe("GitHub Actions hardening", () => { "issues.listComments", "issues.createComment", "pulls.update", + "issues.updateComment", ]); }); @@ -1357,10 +1399,10 @@ describe("GitHub Actions hardening", () => { "pulls.get", "issues.listComments", "issues.createComment", + "issues.updateComment", ]); - const [comment] = callsTo(result, "issues.createComment") as [{ body: string }]; - expect(comment.body).toContain('"titlePrefixedByBot":false'); - expect(comment.body).toContain('"active":true'); + expect(lastEnforcerCommentBody(result)).toContain('"titlePrefixedByBot":false'); + expect(lastEnforcerCommentBody(result)).toContain('"active":true'); }); test("a title that already carries the prefix twice is still enforced", async () => { @@ -1379,12 +1421,13 @@ describe("GitHub Actions hardening", () => { "issues.listComments", "issues.createComment", "graphql", + "issues.updateComment", + "issues.updateComment", ]); // Already prefixed by the `startsWith` test, so no third prefix is added. expect(callsTo(result, "pulls.update")).toEqual([]); - const [comment] = callsTo(result, "issues.createComment") as [{ body: string }]; - expect(comment.body).toContain('"active":true'); - expect(comment.body).toContain('"autoDraftedByBot":true'); + expect(lastEnforcerCommentBody(result)).toContain('"active":true'); + expect(lastEnforcerCommentBody(result)).toContain('"autoDraftedByBot":true'); }); test("with two bot comments, the workflow reads and writes the first", async () => { @@ -1564,27 +1607,37 @@ describe("GitHub Actions hardening", () => { expect(probe.bun).toBe("undefined"); }); - test("an API failure is never swallowed, whatever status it carries", async () => { - // Octokit rejects with a `RequestError` that has a `.status`, and an - // audit round swallowed exactly one code: - // - // catch (error) { if (error.status === 404) return; throw error; } - // - // A green workflow, an un-drafted PR. Drive the failure at several - // statuses so a code-specific catch cannot hide in the gap. + test("a draft GraphQL failure is soft-failed with accurate state and a hard check failure", async () => { + // Observed on PR #626: convertPullRequestToDraft failed with + // "Resource not accessible by integration", the job crashed before + // setFailed, and the PR stayed ready. Soft-catch the draft mutation, + // record autoDraftedByBot:false, explain the fallback, and still fail + // the required check so merge stays blocked. const { script } = await readEnforcePrTarget(); for (const status of [403, 404, 422, 500]) { - await expect( - runEnforcePrTarget(script, { - pr: { base: { ref: "main" }, draft: false }, - failOn: ["graphql"], - failStatus: status, - }), - ).rejects.toThrow(/simulated failure: graphql/); + const result = await runEnforcePrTarget(script, { + pr: { base: { ref: "main" }, draft: false }, + failOn: ["graphql"], + failStatus: status, + }); + expect(methodsOf(result)).toEqual([ + "pulls.get", + "issues.listComments", + "issues.createComment", + "pulls.update", + "graphql", + "issues.updateComment", + ]); + const commentBody = lastEnforcerCommentBody(result); + expect(commentBody).toContain('"autoDraftedByBot":false'); + expect(commentBody).toContain("Automatic draft conversion failed"); + expect(result.warnings.some((w) => w.includes("Could not convert pull request to draft"))).toBe(true); + expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe(true); } - // Same for the title update, which fails before the draft conversion. + // Title update failures still propagate — without the prefix the gate + // has no durable signal on the PR itself. for (const status of [403, 404, 422]) { await expect( runEnforcePrTarget(script, { @@ -1596,40 +1649,44 @@ describe("GitHub Actions hardening", () => { } }); - test("a failed draft conversion propagates, and the state is already stored", async () => { - // Observed on PR #527 (devlog .../050_live_evidence.md): the GraphQL - // mutation failed, the workflow ended in failure, and the PR stayed - // ready — but the comment already claimed `autoDraftedByBot: true`. - // - // The ordering is deliberate: writing the state first is what makes a - // rerun recoverable. This pins both halves of it, including the part - // that is wrong — the recorded state disagrees with reality until the - // rerun. The redesign in 040 owns fixing that; this test documents it so - // the fix is visible when it lands. + test("a failed draft conversion does not claim autoDraftedByBot", async () => { const { script } = await readEnforcePrTarget(); - const observed: string[] = []; - - await expect( - runEnforcePrTarget(script, { - pr: { base: { ref: "main" }, draft: false }, - failOn: ["graphql"], - }).catch((error: unknown) => { - observed.push(String(error)); - throw error; - }), - ).rejects.toThrow(/simulated failure: graphql/); - - // The failure is not swallowed. An audit round wrapped the whole script - // in `try { … } catch {}`, which turns every API failure into a silent - // no-op and a green check; this assertion is what makes that visible. - expect(observed).toHaveLength(1); - - // …and the state comment went out before the mutation that failed. - const upTo = await runEnforcePrTarget(script, { + const result = await runEnforcePrTarget(script, { pr: { base: { ref: "main" }, draft: false }, - failOn: ["pulls.update"], - }).catch(() => null); - expect(upTo).toBeNull(); + failOn: ["graphql"], + }); + const commentBody = lastEnforcerCommentBody(result); + expect(commentBody).toContain('"autoDraftedByBot":false'); + expect(commentBody).toContain('"titlePrefixedByBot":true'); + expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe(true); + }); + + test("a failed ready-for-review conversion keeps ownership active for retry", async () => { + const { script } = await readEnforcePrTarget(); + const result = await runEnforcePrTarget(script, { + pr: { base: { ref: "dev" }, draft: true, title: "[WRONG BRANCH] Add a thing" }, + comments: [ + { + id: 7, + user: { login: "github-actions[bot]" }, + body: [ + "", + ``, + ].join("\n"), + }, + ], + failOn: ["graphql"], + }); + const commentBody = lastEnforcerCommentBody(result); + expect(commentBody).toContain('"active":true'); + expect(commentBody).toContain('"autoDraftedByBot":true'); + expect(commentBody).toContain("Automatic ready-for-review conversion failed"); + expect(result.warnings.some((w) => w.includes("Could not mark pull request ready for review"))).toBe(true); }); }); @@ -1646,6 +1703,7 @@ describe("GitHub Actions hardening", () => { expect(script).toMatch(/storedState\.titlePrefixedByBot/); expect(script).toMatch(/await\s+convertToDraft\(\)/); expect(script).toMatch(/await\s+markReadyForReview\(\)/); + expect(script).toMatch(/core\.setFailed\(/); // Tie each helper to its GraphQL body. Asserting that the call and the // mutation name both appear somewhere leaves a gap: declaring an empty @@ -1673,25 +1731,20 @@ describe("GitHub Actions hardening", () => { expect(script).toMatch(/\n\s*if \(!storedState\?\.active\) \{\n/); expect(script).toMatch(/\n\s*if \(wrongBase\) \{\n/); - // Observed on PR #527 (devlog .../050_live_evidence.md): the state is written - // BEFORE the mutation and is not reconciled when the mutation fails, so a - // failed convertToDraft still records autoDraftedByBot: true. This documents - // that ordering rather than endorsing it — the redesign in 040 owns fixing - // it, and this assertion should change with it. - // - // Scope the comparison to the wrong-base branch: upsertComment is called from - // both branches, so comparing across the whole script would measure whichever - // call happens to come first in the text. + // Pending ownership is written before mutations; convertToDraft runs next; + // a later upsertComment records autoDraftedByBot only after success (#631). const branchStart = script.indexOf("if (wrongBase) {"); expect(branchStart).toBeGreaterThan(-1); const branch = script.slice(branchStart); - const stateWriteIndex = branch.indexOf("await upsertComment("); + const pendingWriteIndex = branch.indexOf("await upsertComment("); const draftCallIndex = branch.indexOf("await convertToDraft()"); - expect(stateWriteIndex).toBeGreaterThan(-1); - expect(draftCallIndex).toBeGreaterThan(stateWriteIndex); + const afterDraftWriteIndex = branch.indexOf("await upsertComment(", draftCallIndex); + expect(pendingWriteIndex).toBeGreaterThan(-1); + expect(draftCallIndex).toBeGreaterThan(-1); + expect(pendingWriteIndex).toBeLessThan(draftCallIndex); + expect(afterDraftWriteIndex).toBeGreaterThan(draftCallIndex); }); - test("docs deployment is pinned, bounded, and scoped to Pages", async () => { const workflow = await readText(".github/workflows/deploy-docs.yml"); diff --git a/tests/cli-account.test.ts b/tests/cli-account.test.ts index 751e8b249..b692ddb85 100644 --- a/tests/cli-account.test.ts +++ b/tests/cli-account.test.ts @@ -1,4 +1,4 @@ -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { PassThrough, Readable } from "node:stream"; import { cmdAccount, classifyAccount, formatAccountTable, type AccountDeps } from "../src/cli/account"; import type { AccountStdin } from "../src/cli/account-api"; @@ -43,6 +43,7 @@ let lastDeletedType: "codex" | "oauth" | "api-key" | null = null; let codexAccounts: Array> = []; let oauthAccounts: Array> = []; let oauthActiveId: string | null = "acct_1"; +let oauthLoginStatus: Record = { loggedIn: false }; let keyEntries: Array> = []; let keyActiveId: string | null = "key_1"; let logs: string[] = []; @@ -285,6 +286,10 @@ async function mockManagementApi(req: Request): Promise { return json({ ok: true, accepted: true }); } + if (req.method === "GET" && url.pathname === "/api/oauth/status") { + return json(oauthLoginStatus); + } + return json({ error: `unhandled mock endpoint: ${req.method} ${url.pathname}` }, 404); } @@ -348,6 +353,7 @@ beforeEach(() => { { id: "acct_2" }, ]; oauthActiveId = "acct_1"; + oauthLoginStatus = { loggedIn: false }; keyEntries = [{ id: "key_1", label: "personal", @@ -1271,5 +1277,24 @@ describe("ocx account CLI (issue #180 matrix)", () => { expect(result.code).toBe(0); expect(requests.some(request => request.path === "/api/oauth/login/code")).toBe(false); }); + + }); + + test("39: a login error wins over a retained OAuth credential", async () => { + oauthLoginStatus = { + loggedIn: true, + done: true, + error: "The credential was saved, but the provider entry was not written.", + }; + const sleepSpy = spyOn(Bun, "sleep").mockImplementation(async () => {}); + try { + const result = await run(["login", "anthropic"]); + + expect(result.code).toBe(2); + expect(result.stderr).toContain("provider entry was not written"); + expect(result.stdout).not.toContain("Logged in to anthropic"); + } finally { + sleepSpy.mockRestore(); + } }); }); diff --git a/tests/cli-provider.test.ts b/tests/cli-provider.test.ts index 2e24a2d8b..cf52454df 100644 --- a/tests/cli-provider.test.ts +++ b/tests/cli-provider.test.ts @@ -97,6 +97,38 @@ describe("ocx provider", () => { } }); + test("provider add rejects a configured Codex account namespace without mutating config", () => { + const { dir, configPath } = freshConfig({ + codexAccountNamespaces: { deepseek: "side-account-id" }, + }); + try { + const before = readFileSync(configPath, "utf8"); + const result = runCli(["provider", "add", "deepseek", "--api-key", "sk-test"], { OPENCODEX_HOME: dir }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("must not collide with a configured Codex account namespace"); + expect(readFileSync(configPath, "utf8")).toBe(before); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test.each(["xai", "deepseek"])("login %s rejects a configured Codex account namespace before prompting", provider => { + const { dir, configPath } = freshConfig({ + codexAccountNamespaces: { [provider]: "side-account-id" }, + }); + try { + const before = readFileSync(configPath, "utf8"); + const result = runCli(["login", provider], { OPENCODEX_HOME: dir }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("must not collide with a configured Codex account namespace"); + expect(readFileSync(configPath, "utf8")).toBe(before); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + test("provider add custom provider requires --adapter and --base-url", () => { const { dir } = freshConfig(); try { diff --git a/tests/cli-status-json.test.ts b/tests/cli-status-json.test.ts index 00da9bc1a..e8a8848d0 100644 --- a/tests/cli-status-json.test.ts +++ b/tests/cli-status-json.test.ts @@ -4,7 +4,7 @@ import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync, mkdirSync import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { selectListenTarget } from "../src/cli/status"; +import { resolveStatusPid, selectListenTarget } from "../src/cli/status"; const repoRoot = dirname(fileURLToPath(new URL("../package.json", import.meta.url))); const cliPath = join(repoRoot, "src", "cli", "index.ts"); @@ -285,6 +285,13 @@ describe("CLI status JSON", () => { expect(target.dashboardUrl).toBe("http://localhost:58195/"); }); + test("resolveStatusPid preserves an authoritative null from live orphan checks", () => { + expect(resolveStatusPid({ pid: null }, 4242)).toBeNull(); + expect(resolveStatusPid({ pid: 1111 }, 4242)).toBe(1111); + expect(resolveStatusPid(null, 4242)).toBe(4242); + expect(resolveStatusPid(null, null)).toBeNull(); + }); + test("listen target brackets raw IPv6 hostnames in the health URL", () => { const target = selectListenTarget( { port: 10100, hostname: "::1" }, diff --git a/tests/codex-account-namespaces.test.ts b/tests/codex-account-namespaces.test.ts new file mode 100644 index 000000000..5083f7c99 --- /dev/null +++ b/tests/codex-account-namespaces.test.ts @@ -0,0 +1,349 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import * as accountLabels from "../src/codex/account-label"; +import { + CODEX_ACCOUNT_LOG_LABEL_RE, + fallbackCodexAccountLogLabel, +} from "../src/codex/account-label"; +import { + codexAccountIdNamespaceCollisionError, + codexAccountNamespaceProviderCollisionError, + codexAccountNamespaceForModel, + hasCodexAccountNamespace, +} from "../src/codex/account-namespace-match"; +import { + appendDefaultCodexAccountNamespace, + codexAccountNamespaceEntries, + defaultCodexAccountNamespaces, + isMainCodexAccountTarget, + isValidCodexAccountNamespaceTarget, +} from "../src/codex/account-namespaces"; +import { MAIN_CODEX_ACCOUNT_ID } from "../src/codex/main-account"; + +describe("Codex account namespace foundations", () => { + test("uses persisted random labels without deriving selectors from aliases or emails", () => { + const privateAlias = "jun@example.test"; + const privateEmail = "account@example.test"; + const privateId = "stored-account-id"; + const namespaces = defaultCodexAccountNamespaces({ + providers: {}, + codexAccounts: [{ + id: privateId, + email: privateEmail, + alias: privateAlias, + logLabel: "p111111", + isMain: false, + }], + }); + + expect(namespaces).toEqual({ main: "@main", p111111: privateId }); + const publicSelectors = Object.keys(namespaces).join(","); + expect(publicSelectors).not.toContain(privateAlias); + expect(publicSelectors).not.toContain(privateEmail); + expect(publicSelectors).not.toContain(privateId); + }); + + test("gives legacy accounts an independent random selector", () => { + const privateId = "legacy-stored-account-id"; + const namespaces = defaultCodexAccountNamespaces({ + providers: {}, + codexAccounts: [{ + id: privateId, + email: "legacy@example.test", + alias: "Legacy Display Name", + isMain: false, + }], + }); + const selector = Object.entries(namespaces) + .find(([, accountId]) => accountId === privateId)?.[0]; + + expect(selector).toMatch(CODEX_ACCOUNT_LOG_LABEL_RE); + expect(selector).not.toBe(fallbackCodexAccountLogLabel(privateId)); + expect(selector).not.toContain(privateId); + }); + + test("fails after bounded attempts when no private-safe selector can be allocated", () => { + const labelSpy = spyOn(accountLabels, "createCodexAccountLogLabel").mockReturnValue("p111111"); + try { + expect(() => defaultCodexAccountNamespaces({ + providers: {}, + codexAccounts: [{ id: "p111111", logLabel: "legacy", isMain: false }], + })).toThrow("Unable to allocate a unique Codex account selector"); + expect(labelSpy).toHaveBeenCalledTimes(16); + } finally { + labelSpy.mockRestore(); + } + }); + + test("never reuses a private account id or its deterministic fallback as a selector", () => { + const pShapedId = "p222222"; + const fallbackId = "legacy-private-account-id"; + const deterministicFallback = fallbackCodexAccountLogLabel(fallbackId); + const namespaces = defaultCodexAccountNamespaces({ + providers: {}, + codexAccounts: [ + { id: pShapedId, logLabel: pShapedId, isMain: false }, + { id: fallbackId, logLabel: deterministicFallback, isMain: false }, + ], + }); + + const selectorFor = (accountId: string) => Object.entries(namespaces) + .find(([, target]) => target === accountId)?.[0]; + expect(selectorFor(pShapedId)).toMatch(CODEX_ACCOUNT_LOG_LABEL_RE); + expect(selectorFor(pShapedId)).not.toBe(pShapedId); + expect(selectorFor(fallbackId)).toMatch(CODEX_ACCOUNT_LOG_LABEL_RE); + expect(selectorFor(fallbackId)).not.toBe(deterministicFallback); + }); + + test("does not expose another account id or deterministic fallback as a selector", () => { + const pShapedPrivateId = "p444444"; + const fallbackOwnerId = "other-private-account-id"; + const otherFallback = fallbackCodexAccountLogLabel(fallbackOwnerId); + const namespaces = defaultCodexAccountNamespaces({ + providers: {}, + codexAccounts: [ + { id: "first-account", logLabel: pShapedPrivateId, isMain: false }, + { id: pShapedPrivateId, logLabel: "p555555", isMain: false }, + { id: "third-account", logLabel: otherFallback, isMain: false }, + { id: fallbackOwnerId, logLabel: "p666666", isMain: false }, + ], + }); + + const publicSelectors = Object.keys(namespaces); + expect(publicSelectors).not.toContain(pShapedPrivateId); + expect(publicSelectors).not.toContain(otherFallback); + }); + + test("suffix allocation cannot land on another private account id", () => { + const privateSuffix = "p888888-2"; + const namespaces = defaultCodexAccountNamespaces({ + providers: { + p888888: { adapter: "openai-chat", baseUrl: "https://example.test/v1" }, + }, + codexAccounts: [ + { id: "first-account", logLabel: "p888888", isMain: false }, + { id: privateSuffix, logLabel: "p999999", isMain: false }, + ], + }); + + expect(Object.keys(namespaces)).not.toContain(privateSuffix); + expect(Object.entries(namespaces).find(([, target]) => target === "first-account")?.[0]) + .toBe("p888888-3"); + }); + + test("avoids provider and combo prefixes when allocating defaults", () => { + const namespaces = defaultCodexAccountNamespaces({ + providers: { p111111: { adapter: "openai-chat", baseUrl: "https://example.test/v1" } }, + combos: { + primary: { + alias: "main/gpt-5.5", + targets: [{ provider: "p111111", model: "gpt-5.5" }], + }, + }, + codexAccounts: [{ + id: "stored-account-id", + email: "account@example.test", + logLabel: "p111111", + isMain: false, + }], + }); + + expect(namespaces).toEqual({ "main-2": "@main", "p111111-2": "stored-account-id" }); + }); + + test("avoids provider names case-insensitively when allocating defaults", () => { + expect(defaultCodexAccountNamespaces({ + providers: { + Main: { adapter: "openai-chat", baseUrl: "https://example.test/v1" }, + }, + })).toEqual({ "main-2": "@main" }); + }); + + test("appends a safe selector without rewriting an explicit map", () => { + const codexAccountNamespaces = { chosen: "existing-account-id", mainAccount: "@main" }; + const config = { providers: {}, codexAccountNamespaces }; + const account = { + id: "new-account-id", + email: "private@example.test", + alias: "Private Display Alias", + logLabel: "p222222", + isMain: false, + }; + + expect(appendDefaultCodexAccountNamespace(config, account)).toBe(true); + expect(config.codexAccountNamespaces).toEqual({ + chosen: "existing-account-id", + mainAccount: "@main", + p222222: "new-account-id", + }); + expect(appendDefaultCodexAccountNamespace(config, account)).toBe(false); + }); + + test("refuses to append an account id already owned by a selector key", () => { + const codexAccountNamespaces = { work: "existing-account-id", mainAccount: "@main" }; + const config = { providers: {}, codexAccountNamespaces }; + + expect(appendDefaultCodexAccountNamespace(config, { + id: "work", + logLabel: "p232323", + isMain: false, + })).toBe(false); + expect(config.codexAccountNamespaces).toEqual({ + work: "existing-account-id", + mainAccount: "@main", + }); + }); + + test("refuses to append the Desktop main account", () => { + const codexAccountNamespaces = { mainAccount: "@main" }; + const config = { providers: {}, codexAccountNamespaces }; + + expect(appendDefaultCodexAccountNamespace(config, { + id: "desktop-id", + logLabel: "p242424", + isMain: true, + })).toBe(false); + expect(config.codexAccountNamespaces).toEqual({ mainAccount: "@main" }); + }); + + test("never generates targets for legacy account ids rejected by the namespace schema", () => { + expect(defaultCodexAccountNamespaces({ + providers: {}, + codexAccounts: [ + { id: "constructor", logLabel: "p242425", isMain: false }, + { id: "valid-account", logLabel: "p242426", isMain: false }, + ], + })).toEqual({ main: "@main", p242426: "valid-account" }); + + const config = { + providers: {}, + codexAccountNamespaces: { mainAccount: "@main" }, + }; + expect(appendDefaultCodexAccountNamespace(config, { + id: "Constructor", + logLabel: "p242427", + isMain: false, + })).toBe(false); + expect(config.codexAccountNamespaces).toEqual({ mainAccount: "@main" }); + }); + + test("append avoids every existing private target and deterministic fallback", () => { + const existingTarget = "p777777"; + const fallbackOwnerId = "existing-private-account-id"; + const fallback = fallbackCodexAccountLogLabel(fallbackOwnerId); + + for (const logLabel of [existingTarget, fallback]) { + const config = { + providers: {}, + codexAccountNamespaces: { + existing: existingTarget, + fallbackOwner: fallbackOwnerId, + mainAccount: "@main", + }, + }; + const newAccountId = `new-account-${logLabel}`; + expect(appendDefaultCodexAccountNamespace(config, { + id: newAccountId, + logLabel, + isMain: false, + })).toBe(true); + const selector = Object.entries(config.codexAccountNamespaces) + .find(([, target]) => target === newAccountId)?.[0]; + expect(selector).toMatch(CODEX_ACCOUNT_LOG_LABEL_RE); + expect(selector).not.toBe(logLabel); + } + }); + + test("append protects pool accounts omitted from an intentionally incomplete map", () => { + const unmappedPrivateId = "p121212"; + const config = { + providers: { + p343434: { adapter: "openai-chat", baseUrl: "https://example.test/v1" }, + }, + codexAccounts: [ + { id: unmappedPrivateId, logLabel: "p565656", isMain: false }, + { id: "p343434-2", logLabel: "p787878", isMain: false }, + ], + codexAccountNamespaces: { mainAccount: "@main" }, + }; + + expect(appendDefaultCodexAccountNamespace(config, { + id: "new-account-id", + logLabel: unmappedPrivateId, + isMain: false, + })).toBe(true); + const selector = Object.entries(config.codexAccountNamespaces) + .find(([, target]) => target === "new-account-id")?.[0]; + expect(selector).toMatch(CODEX_ACCOUNT_LOG_LABEL_RE); + expect(selector).not.toBe(unmappedPrivateId); + + expect(appendDefaultCodexAccountNamespace(config, { + id: "second-new-account-id", + logLabel: "p343434", + isMain: false, + })).toBe(true); + const suffixedSelector = Object.entries(config.codexAccountNamespaces) + .find(([, target]) => target === "second-new-account-id")?.[0]; + expect(suffixedSelector).toBe("p343434-3"); + }); + + test("keeps empty maps inert and distinguishes a pool id named main from the Desktop account", () => { + const empty = { providers: {}, codexAccountNamespaces: {} as Record }; + expect(appendDefaultCodexAccountNamespace(empty, { + id: "side-account-id", + email: "side@example.test", + logLabel: "p333333", + isMain: false, + })).toBe(false); + expect(empty.codexAccountNamespaces).toEqual({}); + + expect(defaultCodexAccountNamespaces({ + providers: {}, + codexAccounts: [ + { id: "main", email: "first@example.test", logLabel: "p454545", isMain: false }, + { id: MAIN_CODEX_ACCOUNT_ID, email: "second@example.test", isMain: false }, + { id: "desktop-row", email: "third@example.test", logLabel: "p464646", isMain: true }, + ], + })).toEqual({ "main-2": "@main", p454545: "main" }); + }); + + test("matches route and account namespaces exactly but provider namespaces case-insensitively", () => { + const inherited = Object.create({ inherited: "account-id" }) as Record; + inherited.side = "side-account-id"; + + expect(hasCodexAccountNamespace(inherited, "side")).toBe(true); + expect(hasCodexAccountNamespace(inherited, "inherited")).toBe(false); + expect(codexAccountNamespaceForModel(inherited, "side/gpt-5.5")).toBe("side"); + expect(codexAccountNamespaceForModel(inherited, "Side/gpt-5.5")).toBeUndefined(); + expect(codexAccountNamespaceForModel(inherited, "gpt-5.5")).toBeUndefined(); + expect(codexAccountNamespaceProviderCollisionError(inherited, "side")) + .toBe("provider name must not collide with a configured Codex account namespace"); + expect(codexAccountNamespaceProviderCollisionError(inherited, "SIDE")) + .toBe("provider name must not collide with a configured Codex account namespace"); + expect(codexAccountNamespaceProviderCollisionError(inherited, "inherited")).toBeUndefined(); + expect(codexAccountIdNamespaceCollisionError(inherited, "side")) + .toBe("account id must not collide with a configured Codex account namespace"); + expect(codexAccountIdNamespaceCollisionError(inherited, "Side")).toBeUndefined(); + expect(codexAccountIdNamespaceCollisionError(inherited, "inherited")).toBeUndefined(); + }); + + test("normalizes only the explicit main sentinel and keeps a pool id named main literal", () => { + expect(isMainCodexAccountTarget("@main")).toBe(true); + expect(isMainCodexAccountTarget("main")).toBe(false); + expect(isMainCodexAccountTarget(MAIN_CODEX_ACCOUNT_ID)).toBe(true); + expect(isValidCodexAccountNamespaceTarget("@main")).toBe(true); + expect(isValidCodexAccountNamespaceTarget(MAIN_CODEX_ACCOUNT_ID)).toBe(false); + expect(isValidCodexAccountNamespaceTarget("side-account_2.test")).toBe(true); + expect(isValidCodexAccountNamespaceTarget(" account ")).toBe(false); + expect(isValidCodexAccountNamespaceTarget("account/id")).toBe(false); + for (const reserved of ["__proto__", "prototype", "constructor", "Constructor"]) { + expect(isValidCodexAccountNamespaceTarget(reserved)).toBe(false); + } + expect(codexAccountNamespaceEntries({ + codexAccountNamespaces: { primary: "@main", poolNamedMain: "main", side: "side-id" }, + })).toEqual([ + ["primary", MAIN_CODEX_ACCOUNT_ID], + ["poolNamedMain", "main"], + ["side", "side-id"], + ]); + }); +}); diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index 585d6eb6c..0b7fa9654 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -7,14 +7,23 @@ import { handleCodexAuthAPI, updateAccountQuota, getAccountQuota, checkAccountIdCollision, getMainChatgptAccountId, markAccountNeedsReauth, isAccountNeedsReauth, clearAccountNeedsReauth, clearAccountQuota, - maskEmail, + clearMainAccountInfoCache, maskEmail, } from "../src/codex/auth-api"; -import { getCodexAccountCredential, readCodexAccountRecord, saveCodexAccountCredential } from "../src/codex/account-store"; import { + getCodexAccountCredential, + listCodexAccountIds, + readCodexAccountRecord, + saveCodexAccountCredential, +} from "../src/codex/account-store"; +import { + clearCodexUpstreamHealth, + clearThreadAccountMap, getCodexUpstreamHealth, recordCodexUpstreamOutcome, + resetCodexRoutingForManualSelection, resolveCodexAccountForThread, } from "../src/codex/routing"; +import { clearPoolRotationState } from "../src/codex/pool-rotation"; import { clearCodexWebSocketRegistry, getTrackedCodexWebSocketCountForAccount, @@ -22,8 +31,9 @@ import { } from "../src/codex/websocket-registry"; import type { OcxConfig } from "../src/types"; import type { WsData } from "../src/server/ws-bridge"; -import { MAIN_CODEX_ACCOUNT_ID } from "../src/codex/main-account"; +import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "../src/codex/main-account"; import { + deleteCodexAccount, reconcileMainCodexAccountRuntimeState, resetMainCodexAccountIdentityTrackingForTests, } from "../src/codex/account-lifecycle"; @@ -80,6 +90,84 @@ function mockCodexWarmupSuccess(): { calls: () => number } { return { calls: () => calls }; } +async function completeMockCodexOAuth(options: { + config: OcxConfig; + requestBody: { id: string; reauth?: boolean }; + oauthAccountId: string; + email: string; + onWarmup: () => void; + usageResponse?: () => Response; +}): Promise<{ startStatus: number; state: { status: string; error?: string } }> { + const oauth = await import("../src/oauth"); + const oauthStore = await import("../src/oauth/store"); + const openUrlMod = await import("../src/lib/open-url"); + await oauthStore.saveCredential("chatgpt", { + access: `access-${options.requestBody.id}`, + refresh: `refresh-${options.requestBody.id}`, + expires: Date.now() + 5 * 60_000, + email: options.email, + accountId: options.oauthAccountId, + }); + const startSpy = spyOn(oauth, "startLoginFlow").mockResolvedValue({ url: "https://example.test/oauth" }); + const statusSpy = spyOn(oauth, "getLoginStatus").mockReturnValue({ + done: true, + loggedIn: true, + } as ReturnType); + const openSpy = spyOn(openUrlMod, "openUrl").mockImplementation(() => {}); + // Mirrors the login-status poll delay in auth-api.ts; other timers are intentionally dropped. + const CODEX_OAUTH_LOGIN_POLL_INTERVAL_MS = 2_000; + const timeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation((( + callback: (...args: unknown[]) => void, + delay?: number, + ...args: unknown[] + ) => { + if (delay === CODEX_OAUTH_LOGIN_POLL_INTERVAL_MS) queueMicrotask(() => callback(...args)); + return 0 as unknown as ReturnType; + }) as typeof setTimeout); + globalThis.fetch = (async (input: RequestInfo | URL) => { + const target = String(input); + if (target === "https://chatgpt.com/backend-api/wham/usage") { + return options.usageResponse?.() + ?? new Response(JSON.stringify({ email: options.email, plan_type: "pro" }), { status: 200 }); + } + if (target === "https://chatgpt.com/backend-api/codex/responses") { + options.onWarmup(); + return new Response('event: response.completed\ndata: {"type":"response.completed"}\n\n', { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + } + return previousFetch(input); + }) as typeof fetch; + + try { + const req = new Request("http://localhost/api/codex-auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(options.requestBody), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), options.config); + const started = await resp!.json() as { flowId: string }; + for (let attempt = 0; attempt < 500; attempt += 1) { + const statusReq = new Request( + `http://localhost/api/codex-auth/login-status?flowId=${started.flowId}`, + { method: "GET" }, + ); + const statusResp = await handleCodexAuthAPI(statusReq, new URL(statusReq.url), options.config); + const state = await statusResp!.json() as { status: string; error?: string }; + if (state.status !== "pending") return { startStatus: resp!.status, state }; + await new Promise(resolve => queueMicrotask(resolve)); + } + throw new Error(`Timed out waiting for Codex OAuth flow ${started.flowId}`); + } finally { + globalThis.fetch = previousFetch; + timeoutSpy.mockRestore(); + startSpy.mockRestore(); + statusSpy.mockRestore(); + openSpy.mockRestore(); + } +} + function seedPoolAccount( config: OcxConfig, account: { @@ -117,6 +205,11 @@ beforeEach(() => { clearAccountNeedsReauth("__main__"); clearAccountQuota(); clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + clearMainAccountInfoCache(); + setMainAccountPlan(null); + clearCodexUpstreamHealth(); + clearThreadAccountMap(); + clearPoolRotationState(); clearCodexWebSocketRegistry(); resetMainCodexAccountIdentityTrackingForTests(); }); @@ -125,6 +218,11 @@ afterEach(() => { clearAccountNeedsReauth("__main__"); clearAccountQuota(); clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + clearMainAccountInfoCache(); + setMainAccountPlan(null); + clearCodexUpstreamHealth(); + clearThreadAccountMap(); + clearPoolRotationState(); clearCodexWebSocketRegistry(); globalThis.fetch = previousFetch; if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; @@ -938,6 +1036,25 @@ describe("codex-auth API", () => { expect(body.error).toContain("Invalid account id"); }); + test.each([ + MAIN_CODEX_ACCOUNT_ID, + "__proto__", + "prototype", + "constructor", + "Constructor", + ])("POST /api/codex-auth/accounts rejects reserved account id %s", async (accountId) => { + enableManualImport(); + const req = new Request("http://localhost/api/codex-auth/accounts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(manualImportBody({ id: accountId })), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), makeConfig()); + expect(resp!.status).toBe(400); + expect(await resp!.json()).toMatchObject({ error: "Invalid account id format" }); + expect(getCodexAccountCredential(accountId)).toBeNull(); + }); + test("POST /api/codex-auth/accounts rejects invalid JSON when manual import is explicitly enabled", async () => { enableManualImport(); const req = new Request("http://localhost/api/codex-auth/accounts", { @@ -1068,6 +1185,62 @@ describe("codex-auth API", () => { }); }); + test("POST /api/codex-auth/accounts rejects an id owned by a namespace before warmup", async () => { + enableManualImport(); + let fetched = false; + globalThis.fetch = (async () => { + fetched = true; + return new Response("unexpected", { status: 500 }); + }) as typeof fetch; + const config = makeConfig({ codexAccountNamespaces: { work: "pool-a" } }); + const before = structuredClone(config); + const req = new Request("http://localhost/api/codex-auth/accounts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(manualImportBody({ id: "work" })), + }); + + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(400); + expect(await resp!.json()).toMatchObject({ + error: "account id must not collide with a configured Codex account namespace", + }); + expect(fetched).toBe(false); + expect(config).toEqual(before); + expect(getCodexAccountCredential("work")).toBeNull(); + }); + + test("manual import rechecks namespace ownership after warmup before persistence", async () => { + enableManualImport(); + const config = makeConfig(); + globalThis.fetch = (async (input: RequestInfo | URL) => { + if (String(input) === "https://chatgpt.com/backend-api/codex/responses") { + config.codexAccountNamespaces = { "manual-race": "pool-a" }; + return new Response('event: response.completed\ndata: {"type":"response.completed"}\n\n', { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + } + return previousFetch(input); + }) as typeof fetch; + const req = new Request("http://localhost/api/codex-auth/accounts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(manualImportBody({ id: "manual-race" })), + }); + + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(400); + expect(await resp!.json()).toMatchObject({ + error: "account id must not collide with a configured Codex account namespace", + }); + expect(config.codexAccounts).toEqual([]); + expect(config.codexAccountNamespaces).toEqual({ "manual-race": "pool-a" }); + expect(getCodexAccountCredential("manual-race")).toBeNull(); + }); + test("PUT /api/codex-auth/auto-switch rejects invalid threshold", async () => { for (const bad of [-1, 101, 50.5, "abc"]) { const req = new Request("http://localhost/api/codex-auth/auto-switch", { @@ -1109,6 +1282,367 @@ describe("codex-auth API", () => { expect(config.activeCodexAccountId).toBe("pool-next"); }); + test("PUT /api/codex-auth/accounts/pause persists exclusion and applies to the next request", async () => { + const config = makeConfig({ + codexAccounts: [ + { id: "pool-active", email: "active@example.test", isMain: false }, + { id: "pool-next", email: "next@example.test", isMain: false }, + ], + activeCodexAccountId: "pool-active", + }); + seedPoolAccount(config, { id: "pool-extra", email: "extra@example.test" }); + saveCodexAccountCredential("pool-active", { + accessToken: "access-active", + refreshToken: "refresh-active", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "acct-active", + }); + saveCodexAccountCredential("pool-next", { + accessToken: "access-next", + refreshToken: "refresh-next", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "acct-next", + }); + updateAccountQuota("pool-active", 5); + updateAccountQuota("pool-next", 10); + updateAccountQuota("pool-extra", 20); + expect(resolveCodexAccountForThread("pause-thread", config)).toBe("pool-active"); + + const req = new Request("http://localhost/api/codex-auth/accounts/pause", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: "pool-active", paused: true }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(200); + expect(await resp!.json()).toMatchObject({ id: "pool-active", paused: true, activeCodexAccountId: "pool-next" }); + expect(config.pausedCodexAccountIds).toEqual(["pool-active"]); + expect(config.activeCodexAccountId).toBe("pool-next"); + expect(resolveCodexAccountForThread("pause-thread", config)).toBe("pool-next"); + }); + + test("pausing the runtime-active round-robin account promotes an eligible replacement", async () => { + const config = makeConfig({ + codexAccounts: [ + { id: "pool-active", email: "active@example.test", isMain: false }, + { id: "pool-next", email: "next@example.test", isMain: false }, + ], + activeCodexAccountId: "pool-active", + accountPoolStrategy: "round-robin", + accountPoolStickyLimit: 2, + }); + for (const id of ["pool-active", "pool-next"]) { + saveCodexAccountCredential(id, { + accessToken: `access-${id}`, + refreshToken: `refresh-${id}`, + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: `acct-${id}`, + }); + } + resetCodexRoutingForManualSelection("pool-active"); + expect(resolveCodexAccountForThread("round-robin-pause", config)).toBe("pool-active"); + + const req = new Request("http://localhost/api/codex-auth/accounts/pause", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: "pool-active", paused: true }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(200); + expect(await resp!.json()).toMatchObject({ + id: "pool-active", + paused: true, + activeCodexAccountId: "pool-next", + }); + expect(config.activeCodexAccountId).toBeUndefined(); + expect(resolveCodexAccountForThread("round-robin-pause", config)).toBe("pool-next"); + }); + + test("pausing a persisted non-active round-robin selection preserves the runtime account", async () => { + const config = makeConfig({ + codexAccounts: [ + { id: "pool-persisted", email: "persisted@example.test", isMain: false }, + { id: "pool-runtime", email: "runtime@example.test", isMain: false }, + ], + activeCodexAccountId: "pool-persisted", + accountPoolStrategy: "round-robin", + accountPoolStickyLimit: 2, + }); + for (const id of ["pool-persisted", "pool-runtime"]) { + saveCodexAccountCredential(id, { + accessToken: `access-${id}`, + refreshToken: `refresh-${id}`, + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: `acct-${id}`, + }); + } + resetCodexRoutingForManualSelection("pool-runtime"); + expect(resolveCodexAccountForThread("runtime-selection", config)).toBe("pool-runtime"); + + const req = new Request("http://localhost/api/codex-auth/accounts/pause", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: "pool-persisted", paused: true }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(200); + expect(await resp!.json()).toMatchObject({ + id: "pool-persisted", + paused: true, + activeCodexAccountId: "pool-runtime", + }); + expect(config.activeCodexAccountId).toBeUndefined(); + expect(resolveCodexAccountForThread("runtime-selection", config)).toBe("pool-runtime"); + }); + + test("PUT /api/codex-auth/accounts/pause-exhausted pauses only freshly confirmed exhausted accounts", async () => { + const config = makeConfig({ + codexAccounts: [ + { id: "exhausted", email: "exhausted@example.test", plan: "plus", isMain: false }, + { id: "available", email: "available@example.test", plan: "plus", isMain: false }, + { id: "free-weekly-only", email: "free@example.test", plan: "free", isMain: false }, + { id: "upgraded-plus", email: "upgraded@example.test", plan: "free", isMain: false }, + { id: "stale-unknown", email: "stale@example.test", plan: "plus", isMain: false }, + { id: "already-paused", email: "paused@example.test", plan: "plus", isMain: false }, + ], + activeCodexAccountId: "exhausted", + pausedCodexAccountIds: ["already-paused"], + }); + for (const account of config.codexAccounts ?? []) { + saveCodexAccountCredential(account.id, { + accessToken: `access-${account.id}`, + refreshToken: `refresh-${account.id}`, + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: account.id, + }); + } + updateAccountQuota("stale-unknown", 100); + expect(resolveCodexAccountForThread("bulk-pause-thread", config)).toBe("exhausted"); + + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + const accountId = new Headers(init?.headers).get("ChatGPT-Account-Id"); + if (accountId === "stale-unknown") return new Response(null, { status: 502 }); + const weekly = accountId === "exhausted" || accountId === "already-paused" || accountId === "free-weekly-only" || accountId === "upgraded-plus" + ? 100 + : 72; + const monthly = accountId === "free-weekly-only" || accountId === "upgraded-plus" ? 20 : 40; + return Response.json({ + plan_type: accountId === "free-weekly-only" ? "free" : "plus", + rate_limit: { + primary_window: { used_percent: weekly, limit_window_seconds: 7 * 24 * 60 * 60 }, + tertiary_window: { used_percent: monthly, limit_window_seconds: 30 * 24 * 60 * 60 }, + }, + }); + }) as typeof fetch; + + const req = new Request("http://localhost/api/codex-auth/accounts/pause-exhausted", { method: "PUT" }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(200); + expect(await resp!.json()).toMatchObject({ + pausedAccountIds: ["exhausted", "upgraded-plus"], + pausedCount: 2, + activeCodexAccountId: "free-weekly-only", + checkedAccountCount: 5, + failedAccountCount: 1, + complete: false, + appliesImmediately: true, + }); + expect(config.pausedCodexAccountIds).toEqual(["already-paused", "exhausted", "upgraded-plus"]); + expect(config.activeCodexAccountId).toBe("free-weekly-only"); + expect(resolveCodexAccountForThread("bulk-pause-thread", config)).toBe("free-weekly-only"); + }); + + test("bulk pause preserves a known Free main plan when WHAM omits plan_type", async () => { + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-access", account_id: "main-account" }, + })); + let call = 0; + globalThis.fetch = (async () => { + call += 1; + return Response.json({ + email: "main@example.test", + ...(call === 1 ? { plan_type: "free" } : {}), + rate_limit: { + primary_window: { used_percent: call === 1 ? 10 : 100, limit_window_seconds: 7 * 24 * 60 * 60 }, + tertiary_window: { used_percent: 20, limit_window_seconds: 30 * 24 * 60 * 60 }, + }, + }); + }) as typeof fetch; + const config = makeConfig(); + + const prime = new Request("http://localhost/api/codex-auth/accounts?refresh=1"); + expect((await handleCodexAuthAPI(prime, new URL(prime.url), config))!.status).toBe(200); + const req = new Request("http://localhost/api/codex-auth/accounts/pause-exhausted", { method: "PUT" }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(200); + expect(await resp!.json()).toMatchObject({ + pausedAccountIds: [], + pausedCount: 0, + checkedAccountCount: 1, + failedAccountCount: 0, + }); + expect(config.pausedCodexAccountIds).toBeUndefined(); + }); + + test("bulk pause fails closed when the main quota plan cannot be established", async () => { + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-access", account_id: "main-account" }, + })); + globalThis.fetch = (async () => Response.json({ + email: "main@example.test", + rate_limit: { + primary_window: { used_percent: 100, limit_window_seconds: 7 * 24 * 60 * 60 }, + tertiary_window: { used_percent: 20, limit_window_seconds: 30 * 24 * 60 * 60 }, + }, + })) as typeof fetch; + const config = makeConfig(); + const req = new Request("http://localhost/api/codex-auth/accounts/pause-exhausted", { method: "PUT" }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(502); + expect(await resp!.json()).toMatchObject({ ok: false, checkedAccountCount: 0, failedAccountCount: 1 }); + expect(config.pausedCodexAccountIds).toBeUndefined(); + }); + + test("bulk pause reports an error when every quota refresh fails", async () => { + const config = makeConfig({ + codexAccounts: [{ id: "offline", email: "offline@example.test", plan: "plus", isMain: false }], + }); + saveCodexAccountCredential("offline", { + accessToken: "offline-access", + refreshToken: "offline-refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "offline", + }); + globalThis.fetch = (async () => new Response(null, { status: 502 })) as typeof fetch; + const req = new Request("http://localhost/api/codex-auth/accounts/pause-exhausted", { method: "PUT" }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(502); + expect(await resp!.json()).toMatchObject({ ok: false, checkedAccountCount: 0, failedAccountCount: 1 }); + expect(config.pausedCodexAccountIds).toBeUndefined(); + }); + + test("bulk pause discards an exhausted result after the account is deleted and recreated", async () => { + const config = makeConfig({ + codexAccounts: [{ id: "reused", email: "old@example.test", plan: "plus", isMain: false }], + }); + saveCodexAccountCredential("reused", { + accessToken: "old-access", + refreshToken: "old-refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "old-account", + }); + let releaseFetch!: () => void; + let markFetchStarted!: () => void; + const fetchStarted = new Promise(resolve => { markFetchStarted = resolve; }); + const fetchGate = new Promise(resolve => { releaseFetch = resolve; }); + globalThis.fetch = (async () => { + markFetchStarted(); + await fetchGate; + return Response.json({ + plan_type: "plus", + rate_limit: { primary_window: { used_percent: 100 } }, + }); + }) as typeof fetch; + + const bulkReq = new Request("http://localhost/api/codex-auth/accounts/pause-exhausted", { method: "PUT" }); + const bulkPromise = handleCodexAuthAPI(bulkReq, new URL(bulkReq.url), config); + await fetchStarted; + const deleteReq = new Request("http://localhost/api/codex-auth/accounts?id=reused", { method: "DELETE" }); + expect((await handleCodexAuthAPI(deleteReq, new URL(deleteReq.url), config))!.status).toBe(200); + config.codexAccounts = [{ id: "reused", email: "new@example.test", plan: "plus", isMain: false }]; + saveCodexAccountCredential("reused", { + accessToken: "new-access", + refreshToken: "new-refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "new-account", + }); + releaseFetch(); + const resp = await bulkPromise; + + expect(resp!.status).toBe(502); + expect(config.pausedCodexAccountIds).toBeUndefined(); + expect(getAccountQuota("reused")).toBeNull(); + }); + + test("PUT /api/codex-auth/accounts/pause-exhausted includes a freshly exhausted main account", async () => { + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-access", account_id: "main-account" }, + })); + globalThis.fetch = (async () => Response.json({ + email: "main@example.test", + plan_type: "plus", + rate_limit: { primary_window: { used_percent: 100 } }, + })) as typeof fetch; + const config = makeConfig(); + const req = new Request("http://localhost/api/codex-auth/accounts/pause-exhausted", { method: "PUT" }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(await resp!.json()).toMatchObject({ + pausedAccountIds: [MAIN_CODEX_ACCOUNT_ID], + pausedCount: 1, + }); + expect(config.pausedCodexAccountIds).toEqual([MAIN_CODEX_ACCOUNT_ID]); + }); + + test("PUT /api/codex-auth/active rejects null when the effective main account is paused", async () => { + const config = makeConfig({ pausedCodexAccountIds: [MAIN_CODEX_ACCOUNT_ID] }); + const req = new Request("http://localhost/api/codex-auth/active", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ accountId: null }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(409); + expect(await resp!.json()).toEqual({ error: "Account is paused" }); + expect(config.activeCodexAccountId).toBeUndefined(); + }); + + test("resuming restores eligibility and manual activation rejects paused accounts", async () => { + const config = makeConfig({ + codexAccounts: [{ id: "work", email: "work@example.test", isMain: false }], + pausedCodexAccountIds: ["work", MAIN_CODEX_ACCOUNT_ID], + }); + const activateReq = new Request("http://localhost/api/codex-auth/active", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ accountId: "work" }), + }); + const activateResp = await handleCodexAuthAPI(activateReq, new URL(activateReq.url), config); + expect(activateResp!.status).toBe(409); + expect(config.activeCodexAccountId).toBeUndefined(); + + const resumeReq = new Request("http://localhost/api/codex-auth/accounts/pause", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: "work", paused: false }), + }); + const resumeResp = await handleCodexAuthAPI(resumeReq, new URL(resumeReq.url), config); + + expect(resumeResp!.status).toBe(200); + expect(config.pausedCodexAccountIds).toEqual([MAIN_CODEX_ACCOUNT_ID]); + }); + + test("account list exposes persisted pause state for main and added accounts", async () => { + const config = makeConfig({ + codexAccounts: [{ id: "work", email: "work@example.test", isMain: false }], + pausedCodexAccountIds: [MAIN_CODEX_ACCOUNT_ID, "work"], + }); + const req = new Request("http://localhost/api/codex-auth/accounts"); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + const data = await resp!.json() as { accounts: Array<{ id: string; paused: boolean }> }; + + expect(data.accounts.find(account => account.id === MAIN_CODEX_ACCOUNT_ID)?.paused).toBe(true); + expect(data.accounts.find(account => account.id === "work")?.paused).toBe(true); + }); + test("PUT /api/codex-auth/accounts/alias changes display metadata only", async () => { const config = makeConfig({ codexAccounts: [{ id: "work", email: "work@example.test", plan: "plus", isMain: false }], @@ -1126,6 +1660,17 @@ describe("codex-auth API", () => { expect(config.activeCodexAccountId).toBe("work"); }); + test("PUT /api/codex-auth/accounts/alias preserves the dedicated main-account error", async () => { + const req = new Request("http://localhost/api/codex-auth/accounts/alias", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: MAIN_CODEX_ACCOUNT_ID, alias: "Desktop" }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), makeConfig()); + expect(resp!.status).toBe(400); + expect(await resp!.json()).toMatchObject({ error: "Main Codex account alias is not configurable" }); + }); + test("PUT /api/codex-auth/auto-switch mutates live runtime config", async () => { const config = makeConfig({ autoSwitchThreshold: 80 }); const req = new Request("http://localhost/api/codex-auth/auto-switch", { @@ -1154,6 +1699,7 @@ describe("codex-auth API", () => { recordCodexUpstreamOutcome(config, "pool-delete", 500); expect(getCodexUpstreamHealth("pool-delete")).not.toBeNull(); markAccountNeedsReauth("pool-delete"); + config.pausedCodexAccountIds = ["pool-delete"]; const closed: { code?: number; reason?: string }[] = []; let cancelled = false; const ws = { @@ -1182,6 +1728,7 @@ describe("codex-auth API", () => { expect(resp!.status).toBe(200); expect(config.codexAccounts).toEqual([]); expect(config.activeCodexAccountId).toBeUndefined(); + expect(config.pausedCodexAccountIds).toBeUndefined(); expect(getCodexAccountCredential("pool-delete")).toBeNull(); expect(getAccountQuota("pool-delete")).toBeNull(); expect(isAccountNeedsReauth("pool-delete")).toBe(false); @@ -1192,6 +1739,98 @@ describe("codex-auth API", () => { expect(getTrackedCodexWebSocketCountForAccount("pool-delete")).toBe(0); }); + test.each([ + MAIN_CODEX_ACCOUNT_ID, + "__proto__", + "__PrOtO__", + "prototype", + "PROTOTYPE", + "constructor", + "Constructor", + ])("DELETE /api/codex-auth/accounts rejects reserved account id %s without mutation", async (accountId) => { + const config = makeConfig({ + activeCodexAccountId: "pool-untouched", + codexAccounts: [{ id: "pool-untouched", email: "pool-untouched@example.test", isMain: false }], + }); + saveCodexAccountCredential("pool-untouched", { + accessToken: "access-untouched", + refreshToken: "refresh-untouched", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "acct-untouched", + }); + const beforeConfig = structuredClone(config); + const beforeCredential = getCodexAccountCredential("pool-untouched"); + + const req = new Request( + `http://localhost/api/codex-auth/accounts?id=${encodeURIComponent(accountId)}`, + { method: "DELETE" }, + ); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(400); + expect(await resp!.json()).toMatchObject({ error: "Invalid account id format" }); + expect(config).toEqual(beforeConfig); + expect(listCodexAccountIds()).toEqual(["pool-untouched"]); + expect(getCodexAccountCredential("pool-untouched")).toEqual(beforeCredential); + }); + + test("DELETE /api/codex-auth/accounts removes a configured legacy reserved account", async () => { + const accountId = "Constructor"; + const config = makeConfig({ + activeCodexAccountId: accountId, + codexAccounts: [{ id: accountId, email: "legacy@example.test", isMain: false }], + }); + saveCodexAccountCredential(accountId, { + accessToken: "legacy-access", + refreshToken: "legacy-refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "legacy-account", + }); + + const req = new Request( + `http://localhost/api/codex-auth/accounts?id=${encodeURIComponent(accountId)}`, + { method: "DELETE" }, + ); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(200); + expect(config.codexAccounts).toEqual([]); + expect(config.activeCodexAccountId).toBeUndefined(); + expect(getCodexAccountCredential(accountId)).toBeNull(); + }); + + test("legacy non-main __main__ is quarantined and removable without touching Desktop auth", async () => { + const config = makeConfig({ + activeCodexAccountId: MAIN_CODEX_ACCOUNT_ID, + codexAccounts: [{ id: MAIN_CODEX_ACCOUNT_ID, email: "invalid-legacy@example.test", isMain: false }], + }); + saveCodexAccountCredential(MAIN_CODEX_ACCOUNT_ID, { + accessToken: "legacy-pool-access", + refreshToken: "legacy-pool-refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "legacy-pool-account", + }); + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "desktop-access", account_id: "desktop-account" }, + })); + + expect(getMainChatgptAccountId()).toBe("desktop-account"); + expect(resolveCodexAccountForThread("legacy-main-row", config)).toBeNull(); + + const req = new Request( + `http://localhost/api/codex-auth/accounts?id=${encodeURIComponent(MAIN_CODEX_ACCOUNT_ID)}`, + { method: "DELETE" }, + ); + + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(200); + expect(config.codexAccounts).toEqual([]); + expect(config.activeCodexAccountId).toBeUndefined(); + expect(getCodexAccountCredential(MAIN_CODEX_ACCOUNT_ID)).toBeNull(); + expect(getMainChatgptAccountId()).toBe("desktop-account"); + }); + test("GET /api/codex-auth/login-status returns idle by default", async () => { const req = new Request("http://localhost/api/codex-auth/login-status", { method: "GET" }); const url = new URL(req.url); @@ -1391,6 +2030,23 @@ describe("codex-auth API", () => { expect(data.error).toContain("Invalid account id"); }); + test.each([ + MAIN_CODEX_ACCOUNT_ID, + "__proto__", + "prototype", + "constructor", + "Constructor", + ])("POST /api/codex-auth/login rejects reserved account id %s before OAuth starts", async (accountId) => { + const req = new Request("http://localhost/api/codex-auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: accountId }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), makeConfig()); + expect(resp!.status).toBe(400); + expect(await resp!.json()).toMatchObject({ error: "Invalid account id format" }); + }); + test("POST /api/codex-auth/login rejects duplicate account id before OAuth starts", async () => { saveCodexAccountCredential("existing", { accessToken: "tok", @@ -1425,6 +2081,30 @@ describe("codex-auth API", () => { expect(data.error).toContain("Account id already exists"); }); + test("POST /api/codex-auth/login rejects an id owned by a namespace before OAuth starts", async () => { + const oauth = await import("../src/oauth"); + const startSpy = spyOn(oauth, "startLoginFlow"); + try { + const config = makeConfig({ codexAccountNamespaces: { work: "pool-a" } }); + const req = new Request("http://localhost/api/codex-auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: "work" }), + }); + + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(400); + expect(await resp!.json()).toMatchObject({ + error: "account id must not collide with a configured Codex account namespace", + }); + expect(startSpy).not.toHaveBeenCalled(); + expect(getCodexAccountCredential("work")).toBeNull(); + } finally { + startSpy.mockRestore(); + } + }); + test("POST /api/codex-auth/login without reauth still rejects existing account id", async () => { const config = makeConfig({ codexAccounts: [{ id: "pool-no-reauth", email: "pool-no-reauth@example.test", isMain: false }], @@ -1481,6 +2161,7 @@ describe("codex-auth API", () => { test("POST /api/codex-auth/login with reauth does not reject existing pool account id", async () => { const config = makeConfig({ + codexAccountNamespaces: { "pool-reauth": "other-account" }, codexAccounts: [{ id: "pool-reauth", email: "pool-reauth@example.test", isMain: false }], }); saveCodexAccountCredential("pool-reauth", { @@ -1510,6 +2191,7 @@ describe("codex-auth API", () => { expect(resp!.status).not.toBe(400); const data = await resp!.json() as { ok?: boolean; flowId?: string; error?: string }; expect(data.error ?? "").not.toContain("already exists"); + expect(data.error ?? "").not.toContain("namespace"); expect(data.ok).toBe(true); expect(data.flowId).toBeTruthy(); expect(startSpy).toHaveBeenCalled(); @@ -1520,6 +2202,104 @@ describe("codex-auth API", () => { } }); + test("OAuth creation rejects a namespace claimed during warmup without persisting", async () => { + const config = makeConfig(); + const result = await completeMockCodexOAuth({ + config, + requestBody: { id: "oauth-race" }, + oauthAccountId: "acct-oauth-race", + email: "oauth-race@example.test", + onWarmup: () => { + config.codexAccountNamespaces = { "oauth-race": "pool-a" }; + }, + }); + + expect(result.startStatus).toBe(200); + expect(result.state).toMatchObject({ + status: "error", + error: "account id must not collide with a configured Codex account namespace", + }); + expect(config.codexAccounts).toEqual([]); + expect(config.codexAccountNamespaces).toEqual({ "oauth-race": "pool-a" }); + expect(getCodexAccountCredential("oauth-race")).toBeNull(); + }); + + test("OAuth reauth cannot recreate an account deleted during warmup", async () => { + const config = makeConfig({ + codexAccounts: [{ id: "reauth-race", email: "reauth-race@example.test", isMain: false }], + }); + saveCodexAccountCredential("reauth-race", { + accessToken: "old-reauth-access", + refreshToken: "old-reauth-refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "acct-reauth-race", + }); + const result = await completeMockCodexOAuth({ + config, + requestBody: { id: "reauth-race", reauth: true }, + oauthAccountId: "acct-reauth-race", + email: "reauth-race@example.test", + onWarmup: () => deleteCodexAccount(config, "reauth-race"), + }); + + expect(result.startStatus).toBe(200); + expect(result.state).toMatchObject({ + status: "error", + error: "Pool account was removed while login was in progress. Add it again as a new account.", + }); + expect(config.codexAccounts).toEqual([]); + expect(getCodexAccountCredential("reauth-race")).toBeNull(); + }); + + test("deleteCodexAccount preserves a main account with the requested id", () => { + const mainAccount = { + id: "shared-account-id", + email: "main@example.test", + isMain: true, + }; + const config = makeConfig({ codexAccounts: [mainAccount] }); + saveCodexAccountCredential("shared-account-id", { + accessToken: "main-access", + refreshToken: "main-refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "shared-account-id", + }); + + deleteCodexAccount(config, "shared-account-id"); + + expect(config.codexAccounts).toEqual([mainAccount]); + expect(getCodexAccountCredential("shared-account-id")).toBeNull(); + }); + + test("OAuth reauth preserves the stored plan when the usage probe fails", async () => { + const config = makeConfig({ + codexAccounts: [{ + id: "reauth-plan", + email: "reauth-plan@example.test", + plan: "business", + isMain: false, + }], + }); + saveCodexAccountCredential("reauth-plan", { + accessToken: "old-plan-access", + refreshToken: "old-plan-refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "acct-reauth-plan", + }); + + const result = await completeMockCodexOAuth({ + config, + requestBody: { id: "reauth-plan", reauth: true }, + oauthAccountId: "acct-reauth-plan", + email: "reauth-plan@example.test", + onWarmup: () => {}, + usageResponse: () => new Response("unavailable", { status: 503 }), + }); + + expect(result.state).toMatchObject({ status: "done" }); + expect(config.codexAccounts?.[0]?.plan).toBe("business"); + }); + test("OAuth pool login excludes self from collision check when reauth", async () => { const source = await Bun.file("src/codex/auth-api.ts").text(); expect(source).toContain("checkAccountIdCollision(oauthAccountId, email, plan, reauth ? accountId : undefined)"); diff --git a/tests/codex-auth-context.test.ts b/tests/codex-auth-context.test.ts index cd7e7d9bc..16c9a921d 100644 --- a/tests/codex-auth-context.test.ts +++ b/tests/codex-auth-context.test.ts @@ -167,6 +167,37 @@ describe("Codex auth context", () => { )).rejects.toBeInstanceOf(CodexPoolAuthenticationError); }); + test("pause excludes new auth selection without invalidating an in-flight context", async () => { + const cfg = config(); + cfg.codexAccounts?.push({ + id: "pool-b", + email: "pool-b@example.test", + isMain: false, + chatgptAccountId: "pool_b_acc", + }); + saveCodexAccountCredential("pool-a", { + accessToken: "pool_a_token", + refreshToken: "pool_a_refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "pool_a_acc", + }); + saveCodexAccountCredential("pool-b", { + accessToken: "pool_b_token", + refreshToken: "pool_b_refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "pool_b_acc", + }); + + const captured = await resolveCodexAuthContext(new Headers(), cfg, "pool"); + expect(captured).toMatchObject({ kind: "pool", accountId: "pool-a" }); + + cfg.pausedCodexAccountIds = ["pool-a"]; + + expect(isCodexAuthContextUsable(captured, cfg)).toBe(true); + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool")) + .resolves.toMatchObject({ kind: "pool", accountId: "pool-b" }); + }); + test("selected pool headers replace inbound main auth", () => { const headers = headersForCodexAuthContext( new Headers({ authorization: "Bearer main_token", "chatgpt-account-id": "main_acc", "openai-beta": "responses=experimental" }), @@ -257,6 +288,160 @@ describe("Codex auth context", () => { } }); + test("reset-derived native cooldowns stay within their confirmed quota group", async () => { + const originalNow = Date.now; + const now = 1_800_000_000_000; + const cfg = config(); + const headers = new Headers({ authorization: "Bearer main_token" }); + saveCodexAccountCredential("pool-a", { + accessToken: "pool_token", + refreshToken: "pool_refresh", + expiresAt: now + 24 * 60 * 60_000, + chatgptAccountId: "pool_acc", + }); + try { + Date.now = () => now; + const resetAt = Math.floor((now + 4 * 24 * 60 * 60_000) / 1000); + recordCodexUpstreamOutcome(cfg, "pool-a", 429, { + now, + resetAt, + modelId: "gpt-5.3-codex-spark", + }); + + // Spark owns a separate quota, so Terra can use the same account. + await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.6-terra" })) + .resolves.toMatchObject({ kind: "pool", accountId: "pool-a" }); + await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.3-codex-spark" })) + .rejects.toBeInstanceOf(CodexAccountCooldownError); + + recordCodexUpstreamOutcome(cfg, "pool-a", 429, { + now, + resetAt, + modelId: "gpt-5.6-terra", + }); + + // Terra and Luna stay in the shared native quota group, while Spark keeps + // its independent cooldown instead of being overwritten by Terra's 429. + await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.6-luna" })) + .rejects.toBeInstanceOf(CodexAccountCooldownError); + await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.3-codex-spark" })) + .rejects.toBeInstanceOf(CodexAccountCooldownError); + + // An explicit retry directive is still account-wide, regardless of the + // originating model's otherwise independent quota group. + recordCodexUpstreamOutcome(cfg, "pool-a", 429, { + now, + retryAfter: "60", + modelId: "gpt-5.3-codex-spark", + }); + await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.6-terra" })) + .rejects.toBeInstanceOf(CodexAccountCooldownError); + } finally { + Date.now = originalNow; + } + }); + + test("a scoped cooldown uses another account without moving the independent native scope", async () => { + const originalNow = Date.now; + const now = 1_800_000_000_000; + const cfg = config(); + cfg.codexAccounts?.push({ + id: "pool-b", + email: "pool-b@example.test", + isMain: false, + chatgptAccountId: "pool_b_acc", + }); + const headers = new Headers({ + authorization: "Bearer main_token", + "x-codex-parent-thread-id": "independent-scope-thread", + }); + for (const accountId of ["pool-a", "pool-b"]) { + saveCodexAccountCredential(accountId, { + accessToken: `${accountId}-token`, + refreshToken: `${accountId}-refresh`, + expiresAt: now + 24 * 60 * 60_000, + chatgptAccountId: `${accountId}-acc`, + }); + } + try { + Date.now = () => now; + // Establish the shared-scope binding first. The Spark fallback below must + // create a second binding rather than replacing this one. + await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.6-terra" })) + .resolves.toMatchObject({ kind: "pool", accountId: "pool-a" }); + + recordCodexUpstreamOutcome(cfg, "pool-a", 429, { + now, + resetAt: Math.floor((now + 4 * 24 * 60 * 60_000) / 1_000), + modelId: "gpt-5.3-codex-spark", + }); + + await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.3-codex-spark" })) + .resolves.toMatchObject({ kind: "pool", accountId: "pool-b" }); + expect(cfg.activeCodexAccountId).toBe("pool-a"); + await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.6-terra" })) + .resolves.toMatchObject({ kind: "pool", accountId: "pool-a" }); + // This second Spark request proves routing retained the peer choice for + // the Spark affinity instead of relying on an auth-layer substitution. + await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.3-codex-spark" })) + .resolves.toMatchObject({ kind: "pool", accountId: "pool-b" }); + } finally { + Date.now = originalNow; + } + }); + + test("a successful Spark recovery probe leaves the shared native cooldown intact", async () => { + const originalNow = Date.now; + const now = 1_800_000_000_000; + const cfg = config(); + const headers = new Headers({ authorization: "Bearer main_token" }); + saveCodexAccountCredential("pool-a", { + accessToken: "pool_token", + refreshToken: "pool_refresh", + expiresAt: now + 24 * 60 * 60_000, + chatgptAccountId: "pool_acc", + }); + try { + Date.now = () => now; + const resetAt = Math.floor((now + 4 * 24 * 60 * 60_000) / 1_000); + recordCodexUpstreamOutcome(cfg, "pool-a", 429, { + now, + resetAt, + modelId: "gpt-5.3-codex-spark", + }); + recordCodexUpstreamOutcome(cfg, "pool-a", 429, { + now, + resetAt, + modelId: "gpt-5.6-terra", + }); + + const probeAt = now + CODEX_QUOTA_PROBE_INTERVAL_MS; + Date.now = () => probeAt; + const sparkProbe = await resolveCodexAuthContext(headers, cfg, "pool", { + modelId: "gpt-5.3-codex-spark", + }); + expect(sparkProbe).toMatchObject({ + kind: "pool", + probeQuotaScope: "spark", + }); + + recordCodexUpstreamOutcome(cfg, "pool-a", 200, { + now: probeAt + 1, + modelId: "gpt-5.3-codex-spark", + probeLeaseId: (sparkProbe as { probeLeaseId?: string }).probeLeaseId, + probeQuotaScope: (sparkProbe as { probeQuotaScope?: "spark" }).probeQuotaScope, + }); + + Date.now = () => probeAt + 1; + await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.3-codex-spark" })) + .resolves.toMatchObject({ kind: "pool", accountId: "pool-a" }); + await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.6-luna" })) + .resolves.toMatchObject({ kind: "pool", probeQuotaScope: "shared" }); + } finally { + Date.now = originalNow; + } + }); + test("expired thread affinity fails closed instead of falling back to main auth", async () => { const now = 1_800_000_000_000; saveCodexAccountCredential("pool-a", { @@ -424,6 +609,20 @@ describe("cooldown error surface", () => { expect(message).toContain("account-…3c21"); }); + test("message identifies a model-scoped cooldown without implying an account-wide block", () => { + const err = new CodexAccountCooldownError( + "acct_9f3c21", + Date.parse("2026-07-26T10:00:00.000Z"), + "reset-derived", + "spark", + ); + + const message = cooldownErrorMessage(err); + + expect(message).toContain("Spark quota is cooling down"); + expect(message).not.toContain("Selected Codex account (account-…3c21) is cooling down"); + }); + test("the main login renders as the alias users actually type", () => { const err = new CodexAccountCooldownError(MAIN_CODEX_ACCOUNT_ID, Date.now() + 60_000); diff --git a/tests/codex-pool-rotation.test.ts b/tests/codex-pool-rotation.test.ts index 16a7f32ea..f16f84dfb 100644 --- a/tests/codex-pool-rotation.test.ts +++ b/tests/codex-pool-rotation.test.ts @@ -156,6 +156,30 @@ describe("accountPoolStrategy new-session routing", () => { expect(new Set(picks).size).toBe(3); }); + test("an independent native scope does not advance the shared round-robin cursor", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "round-robin", + accountPoolStickyLimit: 1, + }); + const now = 1_800_000_000_000; + updateAccountQuota("a", 10); + updateAccountQuota("b", 10); + updateAccountQuota("c", 10); + + expect(resolveCodexAccountForThread(null, config, now, "shared")).toBe("a"); + recordCodexUpstreamOutcome(config, "a", 429, { + now: now + 1, + resetAt: Math.floor((now + 4 * 24 * 60 * 60_000) / 1_000), + modelId: "gpt-5.3-codex-spark", + }); + + // Spark skips A in its own ring. The next shared request still takes B, + // as if the Spark selection had never advanced the shared ring. + expect(resolveCodexAccountForThread(null, config, now + 2, "spark")).toBe("b"); + expect(getEffectiveActiveCodexAccountId(config)).toBe("a"); + expect(resolveCodexAccountForThread(null, config, now + 3, "shared")).toBe("b"); + }); + test("affinity still wins over round-robin", () => { const config = makeThreeAccountConfig({ accountPoolStrategy: "round-robin" }); updateAccountQuota("a", 10); @@ -395,6 +419,38 @@ describe("accountPoolStrategy new-session routing", () => { expect(pickAlternateCodexAccount(config, "a")).toBe("b"); }); + test("scoped reset 429s retain strategy while excluding only the affected native quota", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "fill-first", + activeCodexAccountId: "a", + autoSwitchThreshold: 80, + }); + updateAccountQuota("a", 10); + updateAccountQuota("b", 50); + updateAccountQuota("c", 5); + const now = Date.now(); + const resetAt = Math.floor((now + 4 * 24 * 60 * 60_000) / 1_000); + + recordCodexUpstreamOutcome(config, "b", 429, { + now, + resetAt, + modelId: "gpt-5.3-codex-spark", + }); + + // Fill-first would normally advance a → b, but b is unavailable only to Spark. + expect(pickAlternateCodexAccount(config, "a", now + 1, "spark")).toBe("c"); + expect(pickAlternateCodexAccount(config, "a", now + 1, "shared")).toBe("b"); + + recordCodexUpstreamOutcome(config, "a", 429, { + now, + resetAt, + modelId: "gpt-5.6-terra", + promoteAccountId: "b", + }); + expect(getEffectiveActiveCodexAccountId(config)).toBe("b"); + expect(config.activeCodexAccountId).toBe("a"); + }); + test("RR 429 promotes via ring, not lowest usage", () => { const config = makeThreeAccountConfig({ accountPoolStrategy: "round-robin", diff --git a/tests/codex-routing.test.ts b/tests/codex-routing.test.ts index ce1e14cbb..351e98e84 100644 --- a/tests/codex-routing.test.ts +++ b/tests/codex-routing.test.ts @@ -16,6 +16,8 @@ import { clearThreadAccountMapForAccount, computeCodexUsageScore, getCodexAccountCooldownUntil, + getEffectiveActiveCodexAccountId, + getCodexQuotaHealthSnapshot, getCodexAccountSoftAvoidUntil, getCodexUpstreamHealth, isCodexAccountInCooldown, @@ -37,7 +39,7 @@ import { parseUsageQuota, updateAccountQuota, } from "../src/codex/auth-api"; -import { CODEX_UNKNOWN_USAGE_SCORE } from "../src/codex/quota"; +import { CODEX_UNKNOWN_USAGE_SCORE, isCodexQuotaExhausted } from "../src/codex/quota"; import { MAIN_CODEX_ACCOUNT_ID } from "../src/codex/main-account"; import { routeModel } from "../src/router"; import { consumeForInspection } from "../src/server/relay"; @@ -127,6 +129,16 @@ describe("codex routing", () => { expect(computeCodexUsageScore({})).toBe(CODEX_UNKNOWN_USAGE_SCORE); }); + test("bulk pause exhaustion requires an explicit 100% relevant window", () => { + expect(isCodexQuotaExhausted(null, "plus")).toBe(false); + expect(isCodexQuotaExhausted({}, "plus")).toBe(false); + expect(isCodexQuotaExhausted({ weeklyPercent: 99.9 }, "plus")).toBe(false); + expect(isCodexQuotaExhausted({ weeklyPercent: 100 }, "plus")).toBe(true); + expect(isCodexQuotaExhausted({ monthlyPercent: 100 }, "plus")).toBe(true); + expect(isCodexQuotaExhausted({ weeklyPercent: 100, monthlyPercent: 20 }, "free")).toBe(false); + expect(isCodexQuotaExhausted({ weeklyPercent: 20, monthlyPercent: 100 }, "go")).toBe(true); + }); + test("weekly threshold breach switches new threads", () => { const config = makeConfig(); updateAccountQuota("a", 85); @@ -160,6 +172,21 @@ describe("codex routing", () => { expect(resolveCodexAccountForThread("after-success", config)).toBe("a"); }); + test("paused main account is excluded even when it is the active and lowest-usage candidate", () => { + writeFileSync(join(TEST_DIR, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-access", account_id: "main-chatgpt-id" }, + })); + const config = makeConfig({ + codexAccounts: [{ id: "a", email: "a@test", isMain: false }], + activeCodexAccountId: MAIN_CODEX_ACCOUNT_ID, + pausedCodexAccountIds: [MAIN_CODEX_ACCOUNT_ID], + }); + updateAccountQuota(MAIN_CODEX_ACCOUNT_ID, 1); + updateAccountQuota("a", 20); + + expect(resolveCodexAccountForThread("paused-main", config)).toBe("a"); + }); + test("go plan pool switching ignores the weekly window", () => { const config = makeConfig({ codexAccounts: [ @@ -192,6 +219,25 @@ describe("codex routing", () => { expect(pickLowestUsageCodexAccount(config)).toBe("b"); }); + test("paused accounts are excluded from new selection and existing affinity reuse", () => { + const config = makeConfig(); + updateAccountQuota("a", 10); + updateAccountQuota("b", 20); + expect(resolveCodexAccountForThread("paused-affinity", config)).toBe("a"); + + config.pausedCodexAccountIds = ["a"]; + + expect(pickLowestUsageCodexAccount(config)).toBe("b"); + expect(resolveCodexAccountForThread("paused-affinity", config)).toBe("b"); + }); + + test("all paused accounts fail closed instead of falling back to a configured account", () => { + const config = makeConfig({ pausedCodexAccountIds: ["a", "b"] }); + + expect(pickLowestUsageCodexAccount(config)).toBeNull(); + expect(resolveCodexAccountForThread("all-paused", config)).toBeNull(); + }); + test("upstream outcome classifier separates caller, credential, and transient failures", () => { expect(classifyCodexUpstreamOutcome(200)).toBe("success"); expect(classifyCodexUpstreamOutcome(401)).toBe("credential"); @@ -314,6 +360,67 @@ describe("codex routing", () => { expect(resolveCodexAccountForThread("quota-next", config)).toBe("b"); }); + test("shared native reset cooldown clears affinity and rotates the active account", () => { + const config = makeConfig(); + const now = 1_800_000_000_000; + updateAccountQuota("a", 10); + updateAccountQuota("b", 20); + expect(resolveCodexAccountForThread("shared-quota-existing", config, now)).toBe("a"); + + recordCodexUpstreamOutcome(config, "a", 429, { + now, + resetAt: Math.floor((now + 4 * 24 * 60 * 60_000) / 1_000), + modelId: "gpt-5.6-terra", + }); + + expect(config.activeCodexAccountId).toBe("b"); + expect(resolveCodexAccountForThread("shared-quota-existing", config, now + 1)).toBe("b"); + }); + + test("independent native quota scopes keep separate thread affinities", () => { + const config = makeConfig(); + const now = 1_800_000_000_000; + updateAccountQuota("a", 10); + updateAccountQuota("b", 20); + + // A known shared-model request binds A for this thread. + expect(resolveCodexAccountForThread("scoped-thread", config, now, "shared")).toBe("a"); + + recordCodexUpstreamOutcome(config, "a", 429, { + now: now + 1, + resetAt: Math.floor((now + 4 * 24 * 60 * 60_000) / 1_000), + modelId: "gpt-5.3-codex-spark", + }); + + // Spark sees its scoped cooldown and binds B without moving the global + // active account or the same thread's shared-scope affinity. + expect(resolveCodexAccountForThread("scoped-thread", config, now + 2, "spark")).toBe("b"); + expect(config.activeCodexAccountId).toBe("a"); + expect(getEffectiveActiveCodexAccountId(config)).toBe("a"); + expect(resolveCodexAccountForThread("scoped-thread", config, now + 3, "shared")).toBe("a"); + expect(resolveCodexAccountForThread("scoped-thread", config, now + 4, "spark")).toBe("b"); + }); + + test("429 fallback skips paused candidates", () => { + const config = makeConfig({ + codexAccounts: [ + { id: "a", email: "a@test", isMain: false }, + { id: "b", email: "b@test", isMain: false }, + { id: "c", email: "c@test", isMain: false }, + ], + pausedCodexAccountIds: ["b"], + }); + saveTestCredential("c"); + updateAccountQuota("a", 10); + updateAccountQuota("b", 1); + updateAccountQuota("c", 30); + + recordCodexUpstreamOutcome(config, "a", 429, { retryAfter: "60" }); + + expect(config.activeCodexAccountId).toBe("c"); + expect(resolveCodexAccountForThread("quota-skip-paused", config)).toBe("c"); + }); + test("2xx responses clear transient failures without clearing an unexpired cooldown", () => { const config = makeConfig(); const now = 1_800_000_000_000; @@ -469,6 +576,28 @@ describe("codex routing", () => { expect(health?.lastFailureStatus).toBe(429); }); + test("clearCodexAccountCooldown lifts every live native-model cooldown", () => { + const config = makeConfig(); + const now = 1_800_000_000_000; + const resetAt = Math.floor((now + 4 * 24 * 60 * 60_000) / 1_000); + recordCodexUpstreamOutcome(config, "a", 429, { + now, + resetAt, + modelId: "gpt-5.3-codex-spark", + }); + recordCodexUpstreamOutcome(config, "a", 429, { + now, + resetAt, + modelId: "gpt-5.6-terra", + }); + + expect(getCodexQuotaHealthSnapshot("a", "spark", now + 1)).not.toBeNull(); + expect(getCodexQuotaHealthSnapshot("a", "shared", now + 1)).not.toBeNull(); + expect(clearCodexAccountCooldown("a", now + 1)).toBe(true); + expect(getCodexQuotaHealthSnapshot("a", "spark", now + 1)).toBeNull(); + expect(getCodexQuotaHealthSnapshot("a", "shared", now + 1)).toBeNull(); + }); + test("clearing is a no-op without a live cooldown", () => { const config = makeConfig(); const now = 1_800_000_000_000; @@ -771,6 +900,27 @@ describe("codex routing", () => { expect(resolveCodexAccountForThread("lru-0", config, now + CODEX_THREAD_AFFINITY_MAX_ENTRIES + 2)).toBe("b"); }); + test("thread affinity LRU cap includes legacy and native quota scopes", () => { + const config = makeConfig(); + const now = 1_800_000_000_000; + const threads = Math.floor(CODEX_THREAD_AFFINITY_MAX_ENTRIES / 3) + 1; + + for (let i = 0; i < threads; i++) { + const threadId = `scoped-lru-${i}`; + expect(resolveCodexAccountForThread(threadId, config, now + i * 3)).toBe("a"); + expect(resolveCodexAccountForThread(threadId, config, now + i * 3 + 1, "shared")).toBe("a"); + expect(resolveCodexAccountForThread(threadId, config, now + i * 3 + 2, "spark")).toBe("a"); + } + + // The oldest legacy entry was evicted, while the same thread's later + // shared and Spark entries remain independently affined to A. + config.activeCodexAccountId = "b"; + const after = now + threads * 3; + expect(resolveCodexAccountForThread("scoped-lru-0", config, after, "shared")).toBe("a"); + expect(resolveCodexAccountForThread("scoped-lru-0", config, after + 1, "spark")).toBe("a"); + expect(resolveCodexAccountForThread("scoped-lru-0", config, after + 2)).toBe("b"); + }); + test("generation mismatch invalidates a mapped thread before reuse", () => { const config = makeConfig(); updateAccountQuota("a", 10); diff --git a/tests/combo-management-api.test.ts b/tests/combo-management-api.test.ts index 5bbf5a0af..6d0135755 100644 --- a/tests/combo-management-api.test.ts +++ b/tests/combo-management-api.test.ts @@ -294,6 +294,27 @@ describe("combo management API", () => { }); }); + test("PUT rejects aliases owned by a Codex account namespace without mutating config", async () => { + await withTempHome(async () => { + const config = baseConfig({ codexAccountNamespaces: { side: "side-account-id" } }); + saveConfig(config); + const beforeMemory = structuredClone(config); + const beforeDisk = readFileSync(getConfigPath(), "utf8"); + + const response = await comboApi(config, "PUT", "/api/combos", { + id: "intentional", + combo: { ...VALID_COMBO, alias: "side/gpt-5.5" }, + }); + + expect(response?.status).toBe(409); + expect(await responseJson(response)).toEqual({ + error: "combo alias must not use a configured Codex account namespace", + }); + expect(config).toEqual(beforeMemory); + expect(readFileSync(getConfigPath(), "utf8")).toBe(beforeDisk); + }); + }); + test("PUT rejects invalid and duplicate aliases without memory or disk mutation", async () => { await withTempHome(async () => { const config = baseConfig({ diff --git a/tests/config-user-edits.test.ts b/tests/config-user-edits.test.ts index 87331c326..b3d4897f0 100644 --- a/tests/config-user-edits.test.ts +++ b/tests/config-user-edits.test.ts @@ -6,6 +6,7 @@ import { armClaudeCodeBaseline, getConfigPath, loadConfig, + reconcileLiveConfigFromDisk, saveConfig, saveConfigPreservingClaudeCode, } from "../src/config"; @@ -136,6 +137,42 @@ test("our own change wins a conflict and rebases the baseline", () => { expect((diskConfig().claudeCode as Record).authMode).toBe("proxy"); }); +test("OAuth reconciliation keeps a pending live Claude subtree authoritative", () => { + const live = loadConfig(); + armClaudeCodeBaseline(live); + const persistedBaseline = loadConfig(); + live.claudeCode = { authMode: "subscription", systemEnv: true }; + live.disabledModels = ["pending/model"]; + writeDiskConfig({ + claudeCode: { authMode: "proxy" }, + contextCapValue: 240_000, + }); + + reconcileLiveConfigFromDisk(live, persistedBaseline); + + expect(live.claudeCode).toEqual({ authMode: "subscription", systemEnv: true }); + expect(live.disabledModels).toEqual(["pending/model"]); + expect(live.contextCapValue).toBe(240_000); + + saveConfigPreservingClaudeCode(live); + expect(diskConfig().claudeCode).toEqual({ authMode: "subscription", systemEnv: true }); + expect(diskConfig().disabledModels).toEqual(["pending/model"]); + expect(diskConfig().contextCapValue).toBe(240_000); +}); + +test("OAuth reconciliation adopts a guarded Claude edit that predates its disk snapshot", () => { + const live = loadConfig(); + armClaudeCodeBaseline(live); + writeDiskConfig({ claudeCode: { authMode: "proxy" } }); + const persistedBaseline = loadConfig(); + + reconcileLiveConfigFromDisk(live, persistedBaseline); + + expect(live.claudeCode).toEqual({ authMode: "proxy" }); + saveConfigPreservingClaudeCode(live); + expect(diskConfig().claudeCode).toEqual({ authMode: "proxy" }); +}); + // Structural compare, not JSON.stringify: key order must not fake an external edit. test("a key-order-only difference is not treated as an external edit", () => { const live = loadConfig(); diff --git a/tests/config.test.ts b/tests/config.test.ts index 54cfa7292..d60a1c519 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -66,6 +66,25 @@ function writeResponsesPathConfig(responsesPath: string): void { }); } +function writeAccountNamespaceConfig( + codexAccountNamespaces: unknown, + overrides: Record = {}, +): void { + writeConfig({ + port: 10100, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + }, + defaultProvider: "openai", + codexAccountNamespaces, + ...overrides, + }); +} + describe("opencodex config defaults", () => { test("atomic rename retries transient Windows sharing violations", () => { const sleeps: number[] = []; @@ -232,7 +251,6 @@ describe("opencodex config defaults", () => { }, defaultProvider: "openai", }; - writeConfig({ ...base, injectionModel: "gpt-5.6-terra", @@ -312,6 +330,29 @@ describe("opencodex config defaults", () => { expect(loadConfig().syncCodexSubagentDefaults).toBeUndefined(); }); + test("paused Codex account ids persist and reject malformed values", () => { + const base = { + port: 10100, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + }, + defaultProvider: "openai", + }; + writeConfig({ ...base, pausedCodexAccountIds: ["__main__", "pool-a"] }); + expect(loadConfig().pausedCodexAccountIds).toEqual(["__main__", "pool-a"]); + + for (const invalid of ["pool-a", ["bad/account"], [1]]) { + writeConfig({ ...base, pausedCodexAccountIds: invalid }); + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.source).toBe("fallback"); + expect(diagnostics.error).toContain("pausedCodexAccountIds"); + } + }); + test("loads valid config from OPENCODEX_HOME", () => { writeConfig({ port: 12345, @@ -991,6 +1032,170 @@ describe("opencodex config defaults", () => { expect(isValidProviderName("constructor")).toBe(false); }); + test("persists an explicit Codex account selector map without enabling it by default", () => { + const selectors = { + desktop: "@main", + work: "work-account", + legacy: "work-account", + poolNamedMain: "main", + }; + writeAccountNamespaceConfig(selectors); + + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.error).toBeNull(); + expect(diagnostics.config.codexAccountNamespaces).toEqual(selectors); + expect(Object.hasOwn(getDefaultConfig(), "codexAccountNamespaces")).toBe(false); + }); + + test("validates Claude Desktop profiles and Codex account selectors independently", () => { + const desktopProfile = { + version: 1, + assignments: {}, + defaults: { opus: null, fable: null, sonnet: null, haiku: null }, + }; + writeAccountNamespaceConfig({ main: "@main" }, { claudeCode: { desktopProfile } }); + expect(readConfigDiagnostics()).toMatchObject({ + error: null, + config: { claudeCode: { desktopProfile }, codexAccountNamespaces: { main: "@main" } }, + }); + + writeAccountNamespaceConfig({ main: "@main" }, { + claudeCode: { desktopProfile: { ...desktopProfile, version: 2 } }, + }); + expect(readConfigDiagnostics().error).toContain("claudeCode.desktopProfile"); + + writeAccountNamespaceConfig({ "bad/selector": "account-id" }, { claudeCode: { desktopProfile } }); + expect(readConfigDiagnostics().error).toContain("codexAccountNamespaces.bad/selector"); + }); + + test.each([ + ["null", null], + ["an array", []], + ["a string", "main"], + ] as const)("rejects Codex account selectors stored as %s", (_label, selectors) => { + writeAccountNamespaceConfig(selectors); + + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.source).toBe("fallback"); + expect(diagnostics.error).toContain("codexAccountNamespaces must be a plain object"); + }); + + test.each([ + ["blank", "", "side-account"], + ["surrounding whitespace", " side", "side-account"], + ["a slash", "side/account", "side-account"], + ["a reserved prototype key", "__proto__", "side-account"], + ["a reserved constructor key", "constructor", "side-account"], + ["an empty target", "side", ""], + ["the internal main account id", "side", "__main__"], + ["a reserved prototype target", "side", "__proto__"], + ["a reserved prototype-name target", "side", "prototype"], + ["a reserved constructor target", "side", "Constructor"], + ["a target with whitespace", "side", "side account"], + ["a target with a slash", "side", "account/id"], + ["an overlong target", "side", "a".repeat(65)], + ["a non-string target", "side", 42], + ] as const)("rejects %s in the Codex account selector map", (_label, selector, target) => { + writeAccountNamespaceConfig(Object.fromEntries([[selector, target]])); + + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.source).toBe("fallback"); + expect(diagnostics.error).toContain(`codexAccountNamespaces.${selector}`); + }); + + test.each([ + [ + "a configured provider", + { side: "side-account" }, + { + providers: { + side: { adapter: "openai-chat", baseUrl: "https://side.example.test/v1" }, + }, + defaultProvider: "side", + }, + "must not collide", + ], + [ + "a configured provider with different casing", + { SIDE: "side-account" }, + { + providers: { + side: { adapter: "openai-chat", baseUrl: "https://side.example.test/v1" }, + }, + defaultProvider: "side", + }, + "must not collide", + ], + ["the combo namespace", { combo: "side-account" }, {}, "must not collide"], + ["the combo namespace with different casing", { Combo: "side-account" }, {}, "must not collide"], + ["the canonical OpenAI namespace with different casing", { OpenAI: "side-account" }, {}, "must not collide"], + [ + "the canonical OpenAI provider namespace before legacy migration", + { openai: "side-account" }, + { + providers: { + "openai-multi": { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + }, + defaultProvider: "openai-multi", + }, + "must not collide", + ], + [ + "a combo alias prefix", + { side: "side-account" }, + { + combos: { + intentional: { + alias: "side/gpt-5.5", + targets: [{ provider: "openai", model: "gpt-5.5" }], + }, + }, + }, + "combo alias must not use a configured Codex account namespace", + ], + [ + "a whitespace-padded combo alias prefix", + { side: "side-account" }, + { + combos: { + intentional: { + alias: " side/gpt-5.5 ", + targets: [{ provider: "openai", model: "gpt-5.5" }], + }, + }, + }, + "combo alias must not use a configured Codex account namespace", + ], + [ + "a configured pool account id", + { work: "pool-a" }, + { + codexAccounts: [{ + id: "work", + email: "work@example.test", + isMain: false, + }], + }, + "must not collide with configured Codex pool-account ids or account selector targets", + ], + [ + "another selector target", + { primary: "side", side: "pool-a" }, + {}, + "must not collide with configured Codex pool-account ids or account selector targets", + ], + ] as const)("rejects a Codex account selector colliding with %s", (_label, selectors, overrides, error) => { + writeAccountNamespaceConfig(selectors, overrides); + + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.source).toBe("fallback"); + expect(diagnostics.error).toContain(error); + }); + test("backs up config when defaultProvider only exists on Object prototype", () => { writeConfig({ port: 10100, diff --git a/tests/cursor-native-exec-policy.test.ts b/tests/cursor-native-exec-policy.test.ts index e745cdec3..80ecee7c7 100644 --- a/tests/cursor-native-exec-policy.test.ts +++ b/tests/cursor-native-exec-policy.test.ts @@ -142,6 +142,12 @@ describe("Cursor native exec sandbox policy", () => { expect(deniedShellText).toContain("exec_command"); expect(deniedShellText).toContain("mcp_opencodex-responses_*"); expect(deniedShellText).toContain("Do not tell the user"); + expect(deniedShellText).not.toContain("with the same command"); + expect(deniedShellText).toContain("at most one corrected bridge attempt"); + expect(deniedShellText).toContain("if ($?)"); + expect(deniedShellText).toContain("`&&`/`||` are unsupported parser errors"); + expect(deniedShellText).toContain("do not treat `;` as a substitute for `&&`"); + expect(deniedShellText).toContain("Windows PowerShell 5.1"); expect(deniedShellText).not.toContain("disabled by OpenCodex policy"); expect(deniedShellText).not.toContain("sandbox denial"); expect(deniedShell.message.case).toBe("shellResult"); diff --git a/tests/cursor-tool-definitions.test.ts b/tests/cursor-tool-definitions.test.ts index 652a93515..d418b304b 100644 --- a/tests/cursor-tool-definitions.test.ts +++ b/tests/cursor-tool-definitions.test.ts @@ -324,6 +324,23 @@ describe("Cursor tool definitions", () => { expect(note).toContain("Never tell the user that shell or read access is blocked"); }); + test("adds host-shell-neutral PowerShell and one-retry-stop guidance (#604)", () => { + const note = buildCursorToolGuidanceSystemNote([{ name: "shell_command", description: "Run", parameters: {} }]); + expect(note).toBeDefined(); + if (!note) throw new Error("Expected Cursor tool guidance note"); + + expect(note).toContain("Windows PowerShell 5.1"); + expect(note).toContain("cd /d"); + expect(note).toContain("< { const tools: OcxTool[] = [ { name: "exec_command", description: "Run", parameters: {} }, diff --git a/tests/identity-neutralize.test.ts b/tests/identity-neutralize.test.ts index e0c09867d..2ea559022 100644 --- a/tests/identity-neutralize.test.ts +++ b/tests/identity-neutralize.test.ts @@ -2,7 +2,12 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { CODEX_GPT5_IDENTITY_LINE, NEUTRAL_IDENTITY_LINE, neutralizeIdentity } from "../src/adapters/identity"; +import { + CODEX_GPT5_IDENTITY_LINE, + CODEX_GPT5_IDENTITY_LINE_AGENT, + NEUTRAL_IDENTITY_LINE, + neutralizeIdentity, +} from "../src/adapters/identity"; import { createGoogleAdapter } from "../src/adapters/google"; import { createKiroAdapter } from "../src/adapters/kiro"; import { createOpenAIChatAdapter } from "../src/adapters/openai-chat"; @@ -23,6 +28,12 @@ describe("identity neutralization — central helper", () => { expect(neutralizeIdentity(SYS)).toBe(NEUTRAL_IDENTITY_LINE); }); + test("replaces the Codex CLI 0.145 'an agent' identity variant (#622)", () => { + expect(neutralizeIdentity(CODEX_GPT5_IDENTITY_LINE_AGENT)).toBe(NEUTRAL_IDENTITY_LINE); + expect(neutralizeIdentity("You are Codex, an agent based on GPT-5.4.")).toBe(NEUTRAL_IDENTITY_LINE); + expect(neutralizeIdentity("You are Codex, an agent based on GPT-5.4.1.")).toBe(NEUTRAL_IDENTITY_LINE); + }); + test("never emits the opencodex proxy identity", () => { const out = neutralizeIdentity(`${SYS}\n\nmore context`); expect(out).not.toMatch(/opencodex proxy/i); @@ -32,6 +43,9 @@ describe("identity neutralization — central helper", () => { test("leaves text without the GPT-5 line unchanged", () => { expect(neutralizeIdentity("plain system text")).toBe("plain system text"); + expect(neutralizeIdentity("You are Codex, helpful for reviewing PRs.")).toBe( + "You are Codex, helpful for reviewing PRs.", + ); }); test("neutral line still forbids GPT-5 / OpenAI self-reporting", () => { diff --git a/tests/kiro-review-regressions.test.ts b/tests/kiro-review-regressions.test.ts index 9f11edb1b..b1e18f2d9 100644 --- a/tests/kiro-review-regressions.test.ts +++ b/tests/kiro-review-regressions.test.ts @@ -221,7 +221,13 @@ describe("Kiro review regressions", () => { OAUTH_PROVIDERS.kiro.login = originalLogin; } - expect(events).toEqual(["load-config", "save-config", "settle:false"]); + expect(events).toEqual([ + "load-config", // namespace preflight before browser/CLI auth + "load-config", // provider validation before credential persistence + "load-config", // latest-row upsert after credential persistence + "save-config", + "settle:false", + ]); expect(getAccountSet("kiro")?.activeAccountId).toBe(previousActive); expect(getAccountSet("kiro")?.accounts).toHaveLength(1); expect(getAccountCredential("kiro", previousActive)).toMatchObject({ diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index dd11eacb2..4f9b15abd 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -517,6 +517,44 @@ describe("provider management validation", () => { } }); + test("provider management rejects names owned by a Codex account namespace without mutating config", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + const cfg = { + ...config("127.0.0.1"), + codexAccountNamespaces: { side: "side-account-id" }, + }; + saveConfig(cfg); + const beforeMemory = structuredClone(cfg); + const beforeDisk = readFileSync(join(TEST_DIR, "config.json"), "utf8"); + + const requestUrl = new URL("http://127.0.0.1/api/providers"); + const response = await handleManagementAPI( + new Request(requestUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "side", + provider: { + adapter: "openai-chat", + baseUrl: "https://side.example.test/v1", + }, + }), + }), + requestUrl, + cfg, + { refreshCodexCatalog: async () => {} }, + ); + + expect(response?.status).toBe(409); + expect(await response?.json()).toEqual({ + error: "provider name must not collide with a configured Codex account namespace", + }); + expect(cfg).toEqual(beforeMemory); + expect(readFileSync(join(TEST_DIR, "config.json"), "utf8")).toBe(beforeDisk); + }); + test("provider management rejects base URLs with embedded credentials", async () => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true }); diff --git a/tests/oauth-public-surface.test.ts b/tests/oauth-public-surface.test.ts index aa699e417..43668008d 100644 --- a/tests/oauth-public-surface.test.ts +++ b/tests/oauth-public-surface.test.ts @@ -1,18 +1,24 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { mkdirSync, rmSync } from "node:fs"; import { join } from "node:path"; import { + clearLoginState, + getLoginStatus, isOAuthProvider, isPublicOAuthProvider, listOAuthProviders, OAUTH_PROVIDERS, runLogin, + startLoginFlow, upsertOAuthProvider, } from "../src/oauth"; import { handleManagementAPI } from "../src/server/management-api"; import type { OcxConfig } from "../src/types"; import type { OAuthController } from "../src/oauth/types"; import { getCredential } from "../src/oauth/store"; +import * as oauthStore from "../src/oauth/store"; +import { armClaudeCodeBaseline, loadConfig, saveConfig, saveConfigPreservingClaudeCode } from "../src/config"; +import { isApiAuthRequired, requireApiAuth } from "../src/server/auth-cors"; const TEST_DIR = join(import.meta.dir, ".tmp-oauth-public-surface"); const previousHome = process.env.OPENCODEX_HOME; @@ -32,17 +38,28 @@ function config(): OcxConfig { } beforeEach(() => { + clearLoginState("xai"); rmSync(TEST_DIR, { recursive: true, force: true }); mkdirSync(TEST_DIR, { recursive: true }); process.env.OPENCODEX_HOME = TEST_DIR; }); afterEach(() => { + clearLoginState("xai"); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; rmSync(TEST_DIR, { recursive: true, force: true }); }); +async function waitForOAuthDone(provider: string): Promise> { + for (let attempt = 0; attempt < 200; attempt += 1) { + const status = getLoginStatus(provider); + if (status.done) return status; + await Bun.sleep(5); + } + throw new Error(`OAuth login for ${provider} did not settle`); +} + describe("legacy ChatGPT OAuth public-surface exclusion", () => { test("keeps low-level compatibility but excludes public discovery", () => { expect(isOAuthProvider("chatgpt")).toBe(true); @@ -99,4 +116,421 @@ describe("legacy ChatGPT OAuth public-surface exclusion", () => { expect(getCredential("chatgpt")?.access).toBe("legacy-access"); expect(cfg.providers.chatgpt).toBeUndefined(); }); + + test("OAuth provider creation rejects account namespace collisions before login or mutation", async () => { + const cfg = config(); + cfg.codexAccountNamespaces = { XAI: "side-account-id" }; + const before = structuredClone(cfg.providers); + + expect(() => upsertOAuthProvider(cfg, "xai")).toThrow(/must not collide with a configured Codex account namespace/); + expect(cfg.providers).toEqual(before); + + const routeReq = new Request("http://localhost/api/oauth/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ provider: "xai" }), + }); + const routeResponse = await handleManagementAPI(routeReq, new URL(routeReq.url), cfg); + expect(routeResponse?.status).toBe(409); + expect(await routeResponse?.json()).toEqual({ + error: "provider name must not collide with a configured Codex account namespace", + }); + + saveConfig(cfg); + const originalLogin = OAUTH_PROVIDERS.xai.login; + let loginCalls = 0; + OAUTH_PROVIDERS.xai.login = async () => { + loginCalls += 1; + return { access: "must-not-save", refresh: "must-not-save" }; + }; + try { + await expect(runLogin("xai", {} as OAuthController)).rejects.toThrow( + /must not collide with a configured Codex account namespace/, + ); + } finally { + OAUTH_PROVIDERS.xai.login = originalLogin; + } + expect(loginCalls).toBe(0); + expect(getCredential("xai")).toBeNull(); + + saveConfig(config()); + OAUTH_PROVIDERS.xai.login = async () => { + loginCalls += 1; + const changedDuringLogin = config(); + changedDuringLogin.codexAccountNamespaces = { xai: "side-account-id" }; + saveConfig(changedDuringLogin); + return { access: "must-not-save", refresh: "must-not-save" }; + }; + try { + await expect(runLogin("xai", {} as OAuthController)).rejects.toThrow( + /must not collide with a configured Codex account namespace/, + ); + } finally { + OAUTH_PROVIDERS.xai.login = originalLogin; + } + expect(loginCalls).toBe(1); + expect(getCredential("xai")).toBeNull(); + }); + + test("OAuth provider creation preserves a namespace claimed after credential persistence", async () => { + saveConfig(config()); + const originalLogin = OAUTH_PROVIDERS.xai.login; + const originalSaveCredential = oauthStore.saveCredential; + let changedAfterCredential = false; + let credentialWrites = 0; + OAUTH_PROVIDERS.xai.login = async () => ({ + access: "post-check-access", + refresh: "post-check-refresh", + accountId: "post-check-account", + expires: Date.now() + 60_000, + }); + const saveSpy = spyOn(oauthStore, "saveCredential").mockImplementation(async (provider, credential) => { + credentialWrites += 1; + await originalSaveCredential(provider, credential); + const changed = config(); + changed.defaultProvider = "concurrent"; + changed.providers.concurrent = { + adapter: "openai-chat", + baseUrl: "https://concurrent.example.test/v1", + }; + changed.codexAccountNamespaces = { + XAI: "side-account-id", + retained: "retained-account-id", + }; + saveConfig(changed); + changedAfterCredential = true; + }); + + try { + await expect(runLogin("xai", {} as OAuthController)).rejects.toThrow( + /credential for "xai" was saved, but the provider entry was not written/, + ); + } finally { + OAUTH_PROVIDERS.xai.login = originalLogin; + saveSpy.mockRestore(); + } + + expect(changedAfterCredential).toBe(true); + expect(credentialWrites).toBe(1); + expect(getCredential("xai")?.access).toBe("post-check-access"); + const persistedConfig = loadConfig(); + expect(persistedConfig).toMatchObject({ + defaultProvider: "concurrent", + providers: { + concurrent: { + adapter: "openai-chat", + baseUrl: "https://concurrent.example.test/v1", + }, + }, + codexAccountNamespaces: { + XAI: "side-account-id", + retained: "retained-account-id", + }, + }); + expect(persistedConfig.providers.xai).toBeUndefined(); + }); + + test("OAuth provider creation preserves same-provider key changes during credential persistence", async () => { + const seeded = config(); + seeded.providers.xai = { + ...OAUTH_PROVIDERS.xai.providerConfig, + authMode: "key", + apiKey: "test-key-a", + apiKeyPool: [{ id: "key-a", key: "test-key-a" }], + }; + saveConfig(seeded); + const originalLogin = OAUTH_PROVIDERS.xai.login; + const originalSaveCredential = oauthStore.saveCredential; + OAUTH_PROVIDERS.xai.login = async () => ({ + access: "same-provider-access", + refresh: "same-provider-refresh", + accountId: "same-provider-account", + expires: Date.now() + 60_000, + }); + const saveSpy = spyOn(oauthStore, "saveCredential").mockImplementation(async (provider, credential) => { + await originalSaveCredential(provider, credential); + const changed = loadConfig(); + changed.providers.xai = { + ...changed.providers.xai!, + authMode: "key", + apiKey: "test-key-b", + apiKeyPool: [ + { id: "key-a", key: "test-key-a" }, + { id: "key-b", key: "test-key-b" }, + ], + }; + saveConfig(changed); + }); + + try { + await runLogin("xai", {} as OAuthController); + } finally { + OAUTH_PROVIDERS.xai.login = originalLogin; + saveSpy.mockRestore(); + } + + expect(loadConfig().providers.xai).toMatchObject({ + authMode: "key", + apiKey: "test-key-b", + apiKeyPool: [ + { id: "key-a", key: "test-key-a" }, + { id: "key-b", key: "test-key-b" }, + ], + }); + }); + + test("management OAuth activates the live provider only after persistence succeeds", async () => { + const liveConfig = config(); + saveConfig(liveConfig); + const originalLogin = OAUTH_PROVIDERS.xai.login; + let releaseLogin!: () => void; + const loginGate = new Promise((resolve) => { releaseLogin = resolve; }); + OAUTH_PROVIDERS.xai.login = async (ctrl) => { + ctrl.onAuth({ + url: "https://auth.example.test/authorize", + deviceCode: "test-device-code", + }); + await loginGate; + return { + access: "successful-access", + refresh: "successful-refresh", + accountId: "successful-account", + expires: Date.now() + 60_000, + }; + }; + + try { + const request = new Request("http://localhost/api/oauth/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ provider: "xai" }), + }); + const response = await handleManagementAPI(request, new URL(request.url), liveConfig); + expect(response?.status).toBe(200); + expect(liveConfig.providers.xai).toBeUndefined(); + expect(getLoginStatus("xai").done).toBe(false); + + releaseLogin(); + const status = await waitForOAuthDone("xai"); + expect(status.error).toBeUndefined(); + expect(status.loggedIn).toBe(true); + expect(liveConfig.providers.xai).toEqual(loadConfig().providers.xai); + expect(liveConfig.providers.xai).toBeDefined(); + } finally { + releaseLogin(); + OAUTH_PROVIDERS.xai.login = originalLogin; + clearLoginState("xai"); + } + }); + + test("management OAuth merges its provider row with a pending live provider edit", async () => { + const liveConfig = config(); + saveConfig(liveConfig); + liveConfig.providers.xai = { + ...OAUTH_PROVIDERS.xai.providerConfig, + selectedModels: ["pending-model"], + }; + const originalLogin = OAUTH_PROVIDERS.xai.login; + OAUTH_PROVIDERS.xai.login = async (ctrl) => { + ctrl.onAuth({ + url: "https://auth.example.test/authorize", + deviceCode: "same-provider-device-code", + }); + return { + access: "same-provider-access", + refresh: "same-provider-refresh", + accountId: "same-provider-account", + expires: Date.now() + 60_000, + }; + }; + + try { + const request = new Request("http://localhost/api/oauth/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ provider: "xai" }), + }); + const response = await handleManagementAPI(request, new URL(request.url), liveConfig); + expect(response?.status).toBe(200); + + const status = await waitForOAuthDone("xai"); + expect(status).toMatchObject({ done: true, loggedIn: true }); + expect(status.error).toBeUndefined(); + expect(liveConfig.providers.xai).toMatchObject({ + ...loadConfig().providers.xai, + selectedModels: ["pending-model"], + }); + + saveConfigPreservingClaudeCode(liveConfig); + expect(loadConfig().providers.xai?.selectedModels).toEqual(["pending-model"]); + } finally { + OAUTH_PROVIDERS.xai.login = originalLogin; + clearLoginState("xai"); + } + }); + + test("OAuth settlement preserves the original login failure", async () => { + saveConfig(config()); + const originalLogin = OAUTH_PROVIDERS.xai.login; + OAUTH_PROVIDERS.xai.login = async (ctrl) => { + ctrl.onAuth({ + url: "https://auth.example.test/authorize", + deviceCode: "failed-login-device-code", + }); + throw new Error("browser flow aborted"); + }; + + try { + await startLoginFlow("xai", undefined, { + onSettled: () => { throw new Error("runtime reconciliation failed"); }, + }); + const status = await waitForOAuthDone("xai"); + expect(status.done).toBe(true); + expect(status.error).toBe("browser flow aborted"); + } finally { + OAUTH_PROVIDERS.xai.login = originalLogin; + clearLoginState("xai"); + } + }); + + test("OAuth settlement reports reconciliation failure after a successful login", async () => { + saveConfig(config()); + const originalLogin = OAUTH_PROVIDERS.xai.login; + OAUTH_PROVIDERS.xai.login = async (ctrl) => { + ctrl.onAuth({ + url: "https://auth.example.test/authorize", + deviceCode: "successful-login-device-code", + }); + return { + access: "successful-access", + refresh: "successful-refresh", + accountId: "successful-account", + expires: Date.now() + 60_000, + }; + }; + + try { + await startLoginFlow("xai", undefined, { + onSettled: () => { throw new Error("runtime reconciliation failed"); }, + }); + const status = await waitForOAuthDone("xai"); + expect(status.done).toBe(true); + expect(status.error).toBe("runtime reconciliation failed"); + } finally { + OAUTH_PROVIDERS.xai.login = originalLogin; + clearLoginState("xai"); + } + }); + + test("management OAuth safely reconciles live config after a late namespace claim", async () => { + const liveConfig = config(); + liveConfig.hostname = "0.0.0.0"; + liveConfig.port = 10444; + liveConfig.claudeCode = { authMode: "subscription" }; + saveConfig(liveConfig); + armClaudeCodeBaseline(liveConfig); + // Model visibility has mutated the shared object but yielded before saving. OAuth + // must not replace it with the pre-mutation disk snapshot when login settles. + liveConfig.disabledModels = ["pending/provider-model"]; + const originalLogin = OAUTH_PROVIDERS.xai.login; + const originalSaveCredential = oauthStore.saveCredential; + OAUTH_PROVIDERS.xai.login = async (ctrl) => { + ctrl.onAuth({ + url: "https://auth.example.test/authorize", + deviceCode: "test-device-code", + }); + return { + access: "route-collision-access", + refresh: "route-collision-refresh", + accountId: "route-collision-account", + expires: Date.now() + 60_000, + }; + }; + const saveSpy = spyOn(oauthStore, "saveCredential").mockImplementation(async (provider, credential) => { + await originalSaveCredential(provider, credential); + const concurrentConfig = config(); + concurrentConfig.defaultProvider = "concurrent"; + concurrentConfig.providers.concurrent = { + adapter: "openai-chat", + baseUrl: "https://concurrent.example.test/v1", + }; + concurrentConfig.codexAccountNamespaces = { + XAI: "side-account-id", + retained: "retained-account-id", + }; + concurrentConfig.claudeCode = { authMode: "proxy" }; + saveConfig(concurrentConfig); + }); + + try { + const request = new Request("http://localhost/api/oauth/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ provider: "xai" }), + }); + const response = await handleManagementAPI(request, new URL(request.url), liveConfig); + expect(response?.status).toBe(200); + + const status = await waitForOAuthDone("xai"); + expect(status.loggedIn).toBe(true); + expect(status.error).toMatch(/credential for "xai" was saved, but the provider entry was not written/); + expect(getCredential("xai")?.access).toBe("route-collision-access"); + expect(liveConfig).toMatchObject({ + defaultProvider: "concurrent", + providers: { + concurrent: { + adapter: "openai-chat", + baseUrl: "https://concurrent.example.test/v1", + }, + }, + codexAccountNamespaces: { + XAI: "side-account-id", + retained: "retained-account-id", + }, + }); + expect(liveConfig.providers.xai).toBeUndefined(); + expect(liveConfig.claudeCode?.authMode).toBe("proxy"); + expect(liveConfig.disabledModels).toEqual(["pending/provider-model"]); + // Reconciliation must not make the externally bound socket look loopback-only. + expect(liveConfig.hostname).toBe("0.0.0.0"); + expect(liveConfig.port).toBe(10444); + expect(isApiAuthRequired(liveConfig)).toBe(true); + const forgedLoopbackRequest = new Request("http://localhost:10444/api/config", { + headers: { host: "localhost:10444" }, + }); + expect(requireApiAuth(forgedLoopbackRequest, liveConfig, "management")?.status).toBe(401); + expect(loadConfig().providers.xai).toBeUndefined(); + + // A second disk edit must be compared with the state OAuth just adopted, not + // the stale startup baseline, or this unrelated save would restore "proxy". + const editedAgain = loadConfig(); + editedAgain.hostname = "127.0.0.1"; + editedAgain.port = 11445; + editedAgain.claudeCode = { authMode: "subscription", systemEnv: true }; + saveConfig(editedAgain); + + saveConfigPreservingClaudeCode(liveConfig); + const afterLaterSave = loadConfig(); + expect(afterLaterSave.codexAccountNamespaces).toEqual({ + XAI: "side-account-id", + retained: "retained-account-id", + }); + expect(afterLaterSave.providers.concurrent).toBeDefined(); + expect(afterLaterSave.providers.xai).toBeUndefined(); + expect(afterLaterSave.disabledModels).toEqual(["pending/provider-model"]); + expect(afterLaterSave.claudeCode).toEqual({ authMode: "subscription", systemEnv: true }); + expect(liveConfig.claudeCode).toEqual({ authMode: "subscription", systemEnv: true }); + // Runtime admission remains tied to the open socket, but the next-start + // binding adopted from disk must survive this unrelated live save. + expect(liveConfig.hostname).toBe("0.0.0.0"); + expect(liveConfig.port).toBe(10444); + expect(afterLaterSave.hostname).toBe("127.0.0.1"); + expect(afterLaterSave.port).toBe(11445); + expect(loadConfig()).toMatchObject({ hostname: "127.0.0.1", port: 11445 }); + } finally { + OAUTH_PROVIDERS.xai.login = originalLogin; + saveSpy.mockRestore(); + clearLoginState("xai"); + } + }); }); diff --git a/tests/provider-connection-test.test.ts b/tests/provider-connection-test.test.ts index f4bf64775..55cb7d9e4 100644 --- a/tests/provider-connection-test.test.ts +++ b/tests/provider-connection-test.test.ts @@ -135,6 +135,19 @@ describe("POST /api/providers/test (WP040 connectivity probe)", () => { expect(body.models).toBe(3); }); + test("Together-style top-level /models array is accepted (#617)", async () => { + globalThis.fetch = (async () => new Response(JSON.stringify([{ id: "meta/llama" }, { id: "Qwen/Qwen" }]), { + status: 200, + headers: { "content-type": "application/json" }, + })) as typeof fetch; + const config = baseConfig({ + together: { adapter: "openai-chat", baseUrl: "https://api.together.xyz/v1", apiKey: "tg-key" }, + }); + const { body } = await probe(config, "together"); + expect(body.ok).toBe(true); + expect(body.models).toBe(2); + }); + test("malformed 2xx data is an explicit failure, not a silent pass", async () => { globalThis.fetch = (async () => new Response(JSON.stringify({ nope: true }), { status: 200, diff --git a/tests/provider-workspace-auth.test.ts b/tests/provider-workspace-auth.test.ts index 5d203d3f1..ed69de67b 100644 --- a/tests/provider-workspace-auth.test.ts +++ b/tests/provider-workspace-auth.test.ts @@ -194,7 +194,9 @@ describe("workspace account integration seam", () => { // Health-only reauth_required must reach both aggregate surfaces. expect(pool).toContain("onActiveNeedsReauthChange?.(activePoolNeedsReauth)"); - expect(hook).toContain("accountNeedsReauth(activePoolAccount ?? mainAccount)"); + expect(hook).toContain("accountNeedsReauth(activeAccount)"); + expect(hook).toContain("!activeAccount?.paused &&"); + expect(hook).toContain("activePoolAccount ?? mainAccount"); expect(page).toContain("accountNeedsReauth(active)"); // WP3: background refresh pauses through a token lease, not a boolean read of the // modal flag. Two holders must both release before polling resumes. diff --git a/tests/proxy-liveness.test.ts b/tests/proxy-liveness.test.ts index cdb787fb0..66c09b4fd 100644 --- a/tests/proxy-liveness.test.ts +++ b/tests/proxy-liveness.test.ts @@ -66,7 +66,7 @@ describe("findLiveProxy", () => { }) as typeof fetch, }); - expect(live).toEqual({ pid: 4242, port: 58195 }); + expect(live).toEqual({ pid: 4242, port: 58195, source: "runtime" }); expect(urls).toEqual(["http://127.0.0.1:58195/healthz"]); }); @@ -78,7 +78,7 @@ describe("findLiveProxy", () => { fetchFn: (async () => healthz(OURS)) as typeof fetch, }); - expect(live).toEqual({ pid: 4242, port: 10100 }); + expect(live).toEqual({ pid: 4242, port: 10100, source: "config" }); }); test("a foreign listener on the configured port is not treated as our proxy", async () => { @@ -104,7 +104,7 @@ describe("findLiveProxy", () => { }) as typeof fetch, }); - expect(live).toEqual({ pid: 4242, port: 58195, hostname: "::1" }); + expect(live).toEqual({ pid: 4242, port: 58195, hostname: "::1", source: "runtime" }); expect(urls).toEqual(["http://[::1]:58195/healthz"]); }); @@ -120,7 +120,7 @@ describe("findLiveProxy", () => { // The record's pid 1111 may be dead/reused — synthesizing it would let `ocx stop` // kill an unrelated process via the taskkill/kill fallback. - expect(live).toEqual({ pid: null, port: 58195, hostname: undefined }); + expect(live).toEqual({ pid: null, port: 58195, hostname: undefined, source: "runtime" }); }); test("an orphaned record whose healthz pid mismatches is rejected (config fallback still runs)", async () => { @@ -145,7 +145,7 @@ describe("findLiveProxy", () => { // The runtime probe fails the pid check; the config fallback probes the same port // without a pid expectation and adopts the reported live pid instead. - expect(live).toEqual({ pid: 9999, port: 58195 }); + expect(live).toEqual({ pid: 9999, port: 58195, source: "config" }); }); test("a pidless legacy healthz never promotes an unverified cheap pid to a kill target", async () => { @@ -158,7 +158,7 @@ describe("findLiveProxy", () => { fetchFn: (async () => healthz(legacyBody)) as typeof fetch, }); - expect(live).toEqual({ pid: null, port: 58195, hostname: undefined }); + expect(live).toEqual({ pid: null, port: 58195, hostname: undefined, source: "runtime" }); }); test("a pidless legacy healthz returns the cheap pid once full identity verification echoes it", async () => { @@ -176,7 +176,7 @@ describe("findLiveProxy", () => { }); expect(verified).toEqual([1111]); - expect(live).toEqual({ pid: 1111, port: 58195, hostname: undefined }); + expect(live).toEqual({ pid: 1111, port: 58195, hostname: undefined, source: "runtime" }); }); test("a verifier answering with a DIFFERENT pid than the candidate is rejected (TOCTOU guard)", async () => { @@ -189,6 +189,6 @@ describe("findLiveProxy", () => { fetchFn: (async () => healthz(legacyBody)) as typeof fetch, }); - expect(live).toEqual({ pid: null, port: 58195, hostname: undefined }); + expect(live).toEqual({ pid: null, port: 58195, hostname: undefined, source: "runtime" }); }); }); diff --git a/tests/rate-limit-reset-credits.test.ts b/tests/rate-limit-reset-credits.test.ts index bfe9eaa76..9d66dc1f6 100644 --- a/tests/rate-limit-reset-credits.test.ts +++ b/tests/rate-limit-reset-credits.test.ts @@ -263,7 +263,7 @@ describe("rate-limit reset credits", () => { expect(source).toContain(" onOpenReset(a)} />"); // Next-session still renders BESIDE the ticket; health projection also suppresses // it for projected reauth/cooldown (not only the legacy needsReauth flag). - expect(source).toContain("{isNext(a.id) && !showReauth && !inCooldown && ("); + expect(source).toContain("{isNext(a) && !showReauth && !inCooldown && ("); expect(source).toContain("{t(accountModeState === \"direct\" ? \"codexAuth.poolPrepared\" : \"codexAuth.nextSession\")}"); const styles = await Bun.file("gui/src/styles.css").text(); expect(styles).toContain(".card-badges { display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap; min-width: 0; }"); diff --git a/tests/responses-compaction-routing.test.ts b/tests/responses-compaction-routing.test.ts index 4a6da3c0e..cb50a15fd 100644 --- a/tests/responses-compaction-routing.test.ts +++ b/tests/responses-compaction-routing.test.ts @@ -5,7 +5,20 @@ * fatals on a compaction turn that came back as an ordinary message. */ import { afterEach, describe, expect, test } from "bun:test"; -import { handleResponses } from "../src/server/responses"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleResponses, handleResponsesCompact } from "../src/server/responses"; +import { saveCodexAccountCredential } from "../src/codex/account-store"; +import { + CODEX_QUOTA_PROBE_INTERVAL_MS, + clearCodexUpstreamHealth, + recordCodexUpstreamOutcome, +} from "../src/codex/routing"; +import { + releaseCodexAuthContextProbeLease, + resolveCodexAuthContext, +} from "../src/codex/auth-context"; import { supportsNativeResponsesCompactEndpoint } from "../src/providers/openai-tiers"; import type { OcxConfig, OcxProviderConfig } from "../src/types"; @@ -30,11 +43,33 @@ function keyProviderConfig(overrides: Partial = {}): OcxConfi } as unknown as OcxConfig; } -function compactionRequest(body: Record): Request { +function nativePoolConfig(): OcxConfig { + return { + defaultProvider: "openai", + activeCodexAccountId: "pool-a", + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "pool", + }, + }, + codexAccounts: [{ + id: "pool-a", + email: "pool@example.test", + isMain: false, + chatgptAccountId: "pool_acc", + }], + } as OcxConfig; +} + +function compactionRequest(body: Record, signal?: AbortSignal): Request { return new Request("http://localhost/v1/responses", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), + signal, }); } @@ -112,6 +147,201 @@ describe("supportsNativeResponsesCompactEndpoint (#422)", () => { }); }); +describe("native Codex pool compaction", () => { + test("keeps a Spark reset cooldown separate from a Terra compact request (#590)", async () => { + const testDir = mkdtempSync(join(tmpdir(), "ocx-compact-scope-")); + const previousOpencodexHome = process.env.OPENCODEX_HOME; + const previousCodexHome = process.env.CODEX_HOME; + const config = nativePoolConfig(); + const resetAt = Math.floor((Date.now() + 4 * 24 * 60 * 60_000) / 1_000); + let sparkPhase = true; + try { + process.env.OPENCODEX_HOME = testDir; + process.env.CODEX_HOME = testDir; + clearCodexUpstreamHealth(); + saveCodexAccountCredential("pool-a", { + accessToken: "pool-access-token", + refreshToken: "pool-refresh-token", + expiresAt: Date.now() + 300_000, + chatgptAccountId: "pool_acc", + }); + globalThis.fetch = (async () => { + if (sparkPhase) { + return Response.json({ error: { message: "Spark quota exhausted" } }, { + status: 429, + headers: { "x-codex-primary-reset-at": String(resetAt) }, + }); + } + return jsonResponse(completedPayload("Terra compact response")); + }) as typeof fetch; + const spark = await handleResponsesCompact( + compactionRequest(baseCompactionBody({ model: "gpt-5.3-codex-spark" })), + config, + { model: "", provider: "" }, + ); + expect(spark.status).toBe(429); + + sparkPhase = false; + const cooledSpark = await handleResponsesCompact( + compactionRequest(baseCompactionBody({ model: "gpt-5.3-codex-spark" })), + config, + { model: "", provider: "" }, + ); + expect(cooledSpark.status).toBe(429); + + const terra = await handleResponsesCompact( + compactionRequest(baseCompactionBody({ model: "gpt-5.6-terra" })), + config, + { model: "", provider: "" }, + ); + expect(terra.status).toBe(200); + } finally { + globalThis.fetch = originalFetch; + clearCodexUpstreamHealth(); + rmSync(testDir, { recursive: true, force: true }); + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + } + }); + + test("a cancelled Spark recovery probe releases its compact lease (#590)", async () => { + const testDir = mkdtempSync(join(tmpdir(), "ocx-compact-probe-")); + const previousOpencodexHome = process.env.OPENCODEX_HOME; + const previousCodexHome = process.env.CODEX_HOME; + const originalNow = Date.now; + const now = 1_800_000_000_000; + const probeAt = now + CODEX_QUOTA_PROBE_INTERVAL_MS; + const config = nativePoolConfig(); + const abort = new AbortController(); + let markReadStarted!: () => void; + let releaseBody!: () => void; + const readStarted = new Promise(resolve => { markReadStarted = resolve; }); + const bodyReleased = new Promise(resolve => { releaseBody = resolve; }); + try { + process.env.OPENCODEX_HOME = testDir; + process.env.CODEX_HOME = testDir; + Date.now = () => now; + clearCodexUpstreamHealth(); + saveCodexAccountCredential("pool-a", { + accessToken: "pool-access-token", + refreshToken: "pool-refresh-token", + expiresAt: now + 30 * 60_000, + chatgptAccountId: "pool_acc", + }); + recordCodexUpstreamOutcome(config, "pool-a", 429, { + now, + resetAt: Math.floor((now + 4 * 24 * 60 * 60_000) / 1_000), + modelId: "gpt-5.3-codex-spark", + }); + Date.now = () => probeAt; + globalThis.fetch = (async () => new Response(new ReadableStream({ + async pull(controller) { + markReadStarted(); + await bodyReleased; + controller.enqueue(new TextEncoder().encode("{\"partial\":")); + controller.close(); + }, + }), { status: 200, headers: { "content-type": "application/json" } })) as typeof fetch; + const pending = handleResponsesCompact( + compactionRequest(baseCompactionBody({ model: "gpt-5.3-codex-spark" }), abort.signal), + config, + { model: "", provider: "" }, + ); + await readStarted; + abort.abort(); + releaseBody(); + const cancelled = await pending; + expect(cancelled.status).toBe(499); + + Date.now = () => probeAt + CODEX_QUOTA_PROBE_INTERVAL_MS; + const nextProbe = await resolveCodexAuthContext( + new Headers({ authorization: "Bearer main-token" }), + config, + "pool", + { modelId: "gpt-5.3-codex-spark" }, + ); + expect(nextProbe).toMatchObject({ probeQuotaScope: "spark" }); + releaseCodexAuthContextProbeLease(nextProbe); + } finally { + Date.now = originalNow; + globalThis.fetch = originalFetch; + clearCodexUpstreamHealth(); + rmSync(testDir, { recursive: true, force: true }); + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + } + }); + + test("a Spark recovery probe releases its compact lease when connect is cancelled (#590)", async () => { + const testDir = mkdtempSync(join(tmpdir(), "ocx-compact-connect-probe-")); + const previousOpencodexHome = process.env.OPENCODEX_HOME; + const previousCodexHome = process.env.CODEX_HOME; + const originalNow = Date.now; + const now = 1_800_000_000_000; + const probeAt = now + CODEX_QUOTA_PROBE_INTERVAL_MS; + const config = nativePoolConfig(); + const abort = new AbortController(); + let markFetchStarted!: () => void; + const fetchStarted = new Promise(resolve => { markFetchStarted = resolve; }); + try { + process.env.OPENCODEX_HOME = testDir; + process.env.CODEX_HOME = testDir; + Date.now = () => now; + clearCodexUpstreamHealth(); + saveCodexAccountCredential("pool-a", { + accessToken: "pool-access-token", + refreshToken: "pool-refresh-token", + expiresAt: now + 30 * 60_000, + chatgptAccountId: "pool_acc", + }); + recordCodexUpstreamOutcome(config, "pool-a", 429, { + now, + resetAt: Math.floor((now + 4 * 24 * 60 * 60_000) / 1_000), + modelId: "gpt-5.3-codex-spark", + }); + Date.now = () => probeAt; + globalThis.fetch = ((_url: string | URL | Request, init?: RequestInit) => new Promise((_resolve, reject) => { + const signal = init?.signal; + if (!signal) throw new Error("expected compact request abort signal"); + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); + markFetchStarted(); + })) as typeof fetch; + const pending = handleResponsesCompact( + compactionRequest(baseCompactionBody({ model: "gpt-5.3-codex-spark" }), abort.signal), + config, + { model: "", provider: "" }, + ); + await fetchStarted; + abort.abort(); + const cancelled = await pending; + expect(cancelled.status).toBe(499); + + Date.now = () => probeAt + CODEX_QUOTA_PROBE_INTERVAL_MS; + const nextProbe = await resolveCodexAuthContext( + new Headers({ authorization: "Bearer main-token" }), + config, + "pool", + { modelId: "gpt-5.3-codex-spark" }, + ); + expect(nextProbe).toMatchObject({ probeQuotaScope: "spark" }); + releaseCodexAuthContextProbeLease(nextProbe); + } finally { + Date.now = originalNow; + globalThis.fetch = originalFetch; + clearCodexUpstreamHealth(); + rmSync(testDir, { recursive: true, force: true }); + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + } + }); +}); + describe("routed compaction for key-mode openai-responses (#422)", () => { test("rewrites the wire: no trigger, no tools, summarizer prompt present", async () => { const bodies: Array> = []; diff --git a/tests/responses-state.test.ts b/tests/responses-state.test.ts index 1e3e200ce..705b71121 100644 --- a/tests/responses-state.test.ts +++ b/tests/responses-state.test.ts @@ -522,14 +522,14 @@ describe("Responses previous_response_id state", () => { } }); - test("byte accounting survives restart (sizes recomputed on load)", () => { + test("byte accounting survives restart (sizes recomputed on load)", async () => { setResponseStateByteCapForTests(4_000); try { const bulk = "y".repeat(1_500); const bodyA = { model: "cursor/grok-4.5", input: `${bulk}-a`, store: false }; const jsonA = buildResponseJSON([{ type: "text_delta", text: "ok" }, { type: "done" }], "cursor/grok-4.5"); rememberResponseState(bodyA, jsonA, { cursor: { conversationId: "conv_a" } }, { force: true }); - flushResponseState(); + await flushResponseState(); // Simulated restart: memory wiped, snapshot reloaded lazily. clearResponseStateMemoryForTests(); @@ -625,14 +625,14 @@ describe("Responses previous_response_id state", () => { } }); - test("snapshot survives a simulated restart (memory clear + disk load)", () => { + test("snapshot survives a simulated restart (memory clear + disk load)", async () => { const firstBody = { model: "gpt-5.5", input: "hello" }; const first = buildResponseJSON([ { type: "text_delta", text: "hi" }, { type: "done" }, ], "gpt-5.5"); rememberResponseState(firstBody, first, "cursor_conv_9"); - flushResponseState(); + await flushResponseState(); // Simulate restart: wipe memory, keep the snapshot file. clearResponseStateMemoryForTests(); @@ -747,7 +747,7 @@ describe("Responses previous_response_id state", () => { expect(previousResponseConversationId("resp_v1")).toBe("cursor_v1"); }); - test("persists provider-keyed Cursor and Kiro continuation state across restart", () => { + test("persists provider-keyed Cursor and Kiro continuation state across restart", async () => { const first = buildResponseJSON([ { type: "text_delta", text: "answer", phase: "final_answer" }, { type: "done", endTurn: true }, @@ -760,7 +760,7 @@ describe("Responses previous_response_id state", () => { kiro: { conversationId: "kiro_conv_2" }, }, ); - flushResponseState(); + await flushResponseState(); clearResponseStateMemoryForTests(); expect(previousResponseProviderState(first.id as string)).toEqual({ @@ -771,13 +771,13 @@ describe("Responses previous_response_id state", () => { expect(snapshot.version).toBe(2); }); - test("stale snapshot entries are pruned on load", () => { + test("stale snapshot entries are pruned on load", async () => { const first = buildResponseJSON([ { type: "text_delta", text: "old" }, { type: "done" }, ], "gpt-5.5"); rememberResponseState({ model: "gpt-5.5", input: "old turn" }, first); - flushResponseState(); + await flushResponseState(); clearResponseStateMemoryForTests(); // Rewrite the snapshot with an expired createdAt (2h ago > 1h TTL). @@ -821,7 +821,7 @@ describe("Responses previous_response_id state", () => { expect(expanded.input).toHaveLength(3); }); - test("oversized entries stay in memory but are skipped on disk", () => { + test("oversized entries stay in memory but are skipped on disk", async () => { const big = "x".repeat(3 * 1024 * 1024); // > 2MiB per-entry cap const first = buildResponseJSON([ { type: "text_delta", text: big }, @@ -834,7 +834,7 @@ describe("Responses previous_response_id state", () => { { type: "done" }, ], "gpt-5.5"); rememberResponseState({ model: "gpt-5.5", input: "small turn" }, small); - flushResponseState(); + await flushResponseState(); // In-memory: both expand. expect((expandPreviousResponseInput({ @@ -964,10 +964,10 @@ describe("Responses previous_response_id state", () => { } }); - test("is side-effect free: it never lazy-loads the disk snapshot, prunes, or evicts", () => { + test("is side-effect free: it never lazy-loads the disk snapshot, prunes, or evicts", async () => { const first = buildResponseJSON([{ type: "text_delta", text: "persisted" }, { type: "done" }], "gpt-5.5"); rememberResponseState({ model: "gpt-5.5", input: "persisted turn" }, first); - flushResponseState(); + await flushResponseState(); // Simulated restart: memory wiped, snapshot on disk, `loaded` reset to false. clearResponseStateMemoryForTests(); diff --git a/tests/router.test.ts b/tests/router.test.ts index 82cf6cb5b..ac6fe6d7e 100644 --- a/tests/router.test.ts +++ b/tests/router.test.ts @@ -142,9 +142,9 @@ describe("routeModel registry effort defaults", () => { expect(routeModel({ ...base, providers: { ...base.providers, openai: { ...forward, codexAccountMode: "direct" } } }, "gpt-5.5")) .toMatchObject({ providerName: "openai", codexAccountMode: "direct" }); expect(() => routeModel({ ...base, providers: { ...base.providers, openai: { ...forward, disabled: true } } }, "gpt-5.5")) - .toThrow(NoEnabledOpenAiProviderError); + .toThrow(/requires the canonical openai provider/); const unavailable = { ...base, providers: { "openai-proxy": base.providers["openai-proxy"] } }; - expect(() => routeModel(unavailable, "gpt-5.5")).toThrow(NoEnabledOpenAiProviderError); + expect(() => routeModel(unavailable, "gpt-5.5")).toThrow(/ocx provider add openai/); }); test("rejects legacy chatgpt namespaces even when configured", () => { diff --git a/tests/server-auth.test.ts b/tests/server-auth.test.ts index 2ad8bb630..3c6dc5186 100644 --- a/tests/server-auth.test.ts +++ b/tests/server-auth.test.ts @@ -300,6 +300,7 @@ describe("server local API auth", () => { test("safeConfigDTO redacts provider secrets and exposes booleans", () => { const unsafe = config("127.0.0.1"); unsafe.openaiProviderTierVersion = 1; + unsafe.codexAccountNamespaces = { side: "private-account-id" }; Object.assign(unsafe.providers.openai as unknown as Record, { apiKeyPool: [{ id: "pool-id", key: "pool-secret", label: "private-pool-label" }], modelMaxInputTokens: { "gpt-test": 1000 }, @@ -321,6 +322,7 @@ describe("server local API auth", () => { "virtualModels", "codexAuthContext", "selectedForwardHeaders", "sidecarOutcomeRecorder", "recorder-runtime", "_codexAccountOverride", "_codexAccountRequired", "runtime-token", "override-token", + "codexAccountNamespaces", "private-account-id", ]) expect(serialized).not.toContain(forbidden); expect(dto.providers.openai).toMatchObject({ adapter: "openai-chat", diff --git a/tests/server-combo-failover-e2e.test.ts b/tests/server-combo-failover-e2e.test.ts index c464ed4a0..cd7fe3659 100644 --- a/tests/server-combo-failover-e2e.test.ts +++ b/tests/server-combo-failover-e2e.test.ts @@ -850,6 +850,53 @@ describe("server combo failover 030 activation matrix", () => { expect(getCodexUpstreamHealth(rawAccountId)?.cooldownSource).toBe("retry-after"); }); + test("Spark reset cooldown fails over to the shared native quota on the same account (#590)", async () => { + const rawAccountId = "spark-scope-account"; + const config = comboConfig({ + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "pool", + }, + }, [ + { provider: "openai", model: "gpt-5.3-codex-spark" }, + { provider: "openai", model: "gpt-5.6-terra" }, + ]); + config.codexAccounts = [{ + id: rawAccountId, + email: "pool@example.test", + isMain: false, + logLabel: "pspark1", + }]; + config.activeCodexAccountId = rawAccountId; + config.autoSwitchThreshold = 0; + saveCodexAccountCredential(rawAccountId, { + accessToken: "pool-access-token", + refreshToken: "pool-refresh-token", + expiresAt: Date.now() + 300_000, + chatgptAccountId: "acct-pool-spark", + }); + + const resetAt = Math.floor((Date.now() + 4 * 24 * 60 * 60_000) / 1000); + let upstreamCalls = 0; + customTransientResponse = async () => { + upstreamCalls += 1; + if (upstreamCalls === 1) { + return Response.json({ error: { message: "Spark quota exhausted" } }, { + status: 429, + headers: { "x-codex-primary-reset-at": String(resetAt) }, + }); + } + return Response.json(responsesSuccess("Terra fallback", "gpt-5.6-terra")); + }; + + const response = await post(config); + expect(response.status).toBe(200); + expect(upstreamCalls).toBe(2); + expect(await response.json()).toMatchObject({ model: "gpt-5.6-terra" }); + }); + test("keeps a failed estimate on A without overwriting B reported usage", async () => { customUsageEstimate = model => model === "m1" ? 41 : undefined; customFetchResponse = async request => { diff --git a/tests/videos/fulfill-video.test.ts b/tests/videos/fulfill-video.test.ts new file mode 100644 index 000000000..5baf34faa --- /dev/null +++ b/tests/videos/fulfill-video.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, test } from "bun:test"; +import { parseVideoCallArgs, pollVideoWithHeartbeats, buildVideoResult } from "../../src/images/fulfill-video"; +import type { PollVideoJobFn } from "../../src/images/fulfill-video"; + +const auth = { baseUrl: "https://api.x.ai/v1", token: "t" }; +const noopSleep = async (): Promise => {}; + +describe("parseVideoCallArgs", () => { + test("parses valid args", () => { + const result = parseVideoCallArgs(JSON.stringify({ prompt: "hello", duration: 5, resolution: "720p", aspect_ratio: "16:9" })); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.prompt).toBe("hello"); + expect(result.duration).toBe(5); + expect(result.resolution).toBe("720p"); + expect(result.aspectRatio).toBe("16:9"); + } + }); + + test("accepts input as alias for prompt", () => { + const result = parseVideoCallArgs(JSON.stringify({ input: "world" })); + expect(result.ok).toBe(true); + if (result.ok) expect(result.prompt).toBe("world"); + }); + + test("fails on missing prompt", () => { + const result = parseVideoCallArgs(JSON.stringify({ duration: 5 })); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBe("missing prompt"); + }); + + test("fails on invalid JSON", () => { + const result = parseVideoCallArgs("not json"); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBe("invalid arguments JSON"); + }); + + test("fails on null", () => { + const result = parseVideoCallArgs("null"); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBe("invalid arguments JSON"); + }); + + test("clamps duration to 1-15", () => { + const result = parseVideoCallArgs(JSON.stringify({ prompt: "test", duration: 100 })); + expect(result.ok).toBe(true); + if (result.ok) expect(result.duration).toBe(15); + }); + + test("clamps duration minimum to 1", () => { + const result = parseVideoCallArgs(JSON.stringify({ prompt: "test", duration: 0 })); + expect(result.ok).toBe(true); + if (result.ok) expect(result.duration).toBe(1); + }); + + test("rejects invalid resolution", () => { + const result = parseVideoCallArgs(JSON.stringify({ prompt: "test", resolution: "1080p" })); + expect(result.ok).toBe(true); + if (result.ok) expect(result.resolution).toBeUndefined(); + }); + + test("rejects invalid aspect_ratio", () => { + const result = parseVideoCallArgs(JSON.stringify({ prompt: "test", aspect_ratio: "5:4" })); + expect(result.ok).toBe(true); + if (result.ok) expect(result.aspectRatio).toBeUndefined(); + }); +}); + +describe("pollVideoWithHeartbeats", () => { + async function drain( + pollFn: PollVideoJobFn, + timeoutMs = 60_000, + ): Promise<{ heartbeats: string[]; result: { ok: true; videoUrl: string } | { ok: false; error: string } }> { + const ac = new AbortController(); + const gen = pollVideoWithHeartbeats("r1", auth, ac.signal, timeoutMs, noopSleep, pollFn); + const heartbeats: string[] = []; + for (;;) { + const { value, done } = await gen.next(); + if (done) return { heartbeats, result: value }; + heartbeats.push(value.message); + } + } + + test("returns done on first poll", async () => { + const { result } = await drain(async () => ({ + status: "done", + videoUrl: "https://cdn.x.ai/v.mp4", + })); + expect(result.ok).toBe(true); + if (result.ok) expect(result.videoUrl).toBe("https://cdn.x.ai/v.mp4"); + }); + + test("yields at least one heartbeat before returning", async () => { + let callCount = 0; + const { heartbeats, result } = await drain(async () => { + callCount++; + return callCount >= 2 + ? { status: "done" as const, videoUrl: "https://x.ai/v.mp4" } + : { status: "processing" as const }; + }); + expect(heartbeats.length).toBeGreaterThanOrEqual(1); + expect(result.ok).toBe(true); + }); + + test("returns failed status", async () => { + const { result } = await drain(async () => ({ status: "failed" })); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBe("video generation failed"); + }); + + test("returns timeout error when timeoutMs exceeded", async () => { + const { result } = await drain(async () => ({ status: "processing" }), 0); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("timed out"); + }); + + test("returns error when done but no videoUrl", async () => { + const { result } = await drain(async () => ({ status: "done" })); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("no video URL"); + }); + + test("stops retrying on permanent 4xx poll error", async () => { + let pollCount = 0; + const { result } = await drain(async () => { + pollCount++; + const err = new Error("xAI videos poll API returned 401") as Error & { status: number }; + err.status = 401; + throw err; + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("permanently"); + expect(pollCount).toBe(1); + }); +}); + +describe("buildVideoResult", () => { + test("builds success result", () => { + const result = buildVideoResult("/tmp/vid-123.mp4", "dance", "grok-imagine-video"); + expect(result.ok).toBe(true); + expect(result.path).toBe("/tmp/vid-123.mp4"); + expect(result.prompt).toBe("dance"); + expect(result.model).toBe("grok-imagine-video"); + expect(result.files).toEqual(["/tmp/vid-123.mp4"]); + expect(result.count).toBe(1); + expect(result.markdown).toContain("vid-123.mp4"); + }); + + test("uses file:// URL in markdown", () => { + const result = buildVideoResult("/tmp/vid-123.mp4", "dance", "grok-imagine-video"); + expect(result.markdown).toContain("file://"); + }); +}); diff --git a/tests/videos/plan-video.test.ts b/tests/videos/plan-video.test.ts new file mode 100644 index 000000000..84b6127b0 --- /dev/null +++ b/tests/videos/plan-video.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from "bun:test"; +import { planVideoBridge } from "../../src/images/plan"; +import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../../src/types"; +import { VIDEO_GEN_TOOL_NAME } from "../../src/images/synthetic-tool"; + +function makeConfig(overrides: Partial = {}): OcxConfig { + const xai: OcxProviderConfig = { + name: "xai", + baseUrl: "https://api.x.ai/v1", + authMode: "key", + apiKey: "xai-test-key", + }; + return { + providers: { xai }, + ...overrides, + } as unknown as OcxConfig; +} + +function makeParsed(): OcxParsedRequest { + return { stream: true, context: { messages: [] } } as unknown as OcxParsedRequest; +} + +function makeProvider(host: string): OcxProviderConfig { + return { baseUrl: `https://${host}`, authMode: "key", apiKey: "other-key" } as unknown as OcxProviderConfig; +} + +describe("planVideoBridge", () => { + test("returns undefined when videoBridgeEnabled is not true", async () => { + const config = makeConfig({ images: { videoBridgeEnabled: false } } as unknown as OcxConfig); + const plan = await planVideoBridge(config, makeParsed(), makeProvider("api.anthropic.com")); + expect(plan).toBeUndefined(); + }); + + test("returns undefined when videoBridgeEnabled is missing", async () => { + const config = makeConfig({ images: {} } as unknown as OcxConfig); + const plan = await planVideoBridge(config, makeParsed(), makeProvider("api.anthropic.com")); + expect(plan).toBeUndefined(); + }); + + test("returns plan when enabled with valid xAI provider", async () => { + const config = makeConfig({ images: { videoBridgeEnabled: true } } as unknown as OcxConfig); + const plan = await planVideoBridge(config, makeParsed(), makeProvider("api.anthropic.com")); + expect(plan).toBeDefined(); + expect(plan!.model).toBe("grok-imagine-video"); + expect(plan!.auth.token).toBe("xai-test-key"); + expect(plan!.auth.baseUrl).toBe("https://api.x.ai/v1"); + expect(plan!.toolNames.has(VIDEO_GEN_TOOL_NAME)).toBe(true); + }); + + test("returns undefined for OpenAI native passthrough", async () => { + const config = makeConfig({ images: { videoBridgeEnabled: true } } as unknown as OcxConfig); + const plan = await planVideoBridge(config, makeParsed(), makeProvider("api.openai.com")); + expect(plan).toBeUndefined(); + }); + + test("returns undefined when no xAI provider available", async () => { + const config: OcxConfig = { + providers: { anthropic: makeProvider("api.anthropic.com") }, + images: { videoBridgeEnabled: true }, + } as unknown as OcxConfig; + const plan = await planVideoBridge(config, makeParsed(), makeProvider("api.anthropic.com")); + expect(plan).toBeUndefined(); + }); + + test("returns undefined when xAI provider uses oauth (no API key)", async () => { + const config: OcxConfig = { + providers: { xai: { baseUrl: "https://api.x.ai/v1", authMode: "oauth", apiKey: undefined } }, + images: { videoBridgeEnabled: true }, + } as unknown as OcxConfig; + const plan = await planVideoBridge(config, makeParsed(), makeProvider("api.anthropic.com")); + expect(plan).toBeUndefined(); + }); + + test("respects custom videoBridgeModel", async () => { + const config = makeConfig({ images: { videoBridgeEnabled: true, videoBridgeModel: "custom-video-model" } } as unknown as OcxConfig); + const plan = await planVideoBridge(config, makeParsed(), makeProvider("api.anthropic.com")); + expect(plan).toBeDefined(); + expect(plan!.model).toBe("custom-video-model"); + }); +}); diff --git a/tests/videos/xai-video-client.test.ts b/tests/videos/xai-video-client.test.ts new file mode 100644 index 000000000..fd8d77348 --- /dev/null +++ b/tests/videos/xai-video-client.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, test, mock, afterEach } from "bun:test"; +import { submitVideoJob, pollVideoJob } from "../../src/images/xai-video-client"; + +const auth = { baseUrl: "https://api.x.ai/v1", token: "test-key" }; + +const originalFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = originalFetch; + mock.restore(); +}); + +function mockFetchResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("submitVideoJob", () => { + test("returns request_id from response", async () => { + const fetchMock = mock(() => Promise.resolve(mockFetchResponse({ request_id: "vid-123" }))); + globalThis.fetch = fetchMock as typeof globalThis.fetch; + + const result = await submitVideoJob({ prompt: "a cat playing piano" }, auth); + expect(result.requestId).toBe("vid-123"); + }); + + test("accepts id field as fallback", async () => { + const fetchMock = mock(() => Promise.resolve(mockFetchResponse({ id: "vid-456" }))); + globalThis.fetch = fetchMock as typeof globalThis.fetch; + + const result = await submitVideoJob({ prompt: "sunset" }, auth); + expect(result.requestId).toBe("vid-456"); + }); + + test("sends correct POST body", async () => { + let capturedBody: string | undefined; + const fetchMock = mock((url: string, init: RequestInit) => { + capturedBody = init.body as string; + return Promise.resolve(mockFetchResponse({ request_id: "r1" })); + }); + globalThis.fetch = fetchMock as typeof globalThis.fetch; + + await submitVideoJob( + { prompt: "dance", model: "grok-imagine-video", duration: 5, resolution: "720p", aspectRatio: "16:9" }, + auth, + ); + + const body = JSON.parse(capturedBody!); + expect(body.prompt).toBe("dance"); + expect(body.model).toBe("grok-imagine-video"); + expect(body.duration).toBe(5); + expect(body.resolution).toBe("720p"); + expect(body.aspect_ratio).toBe("16:9"); + }); + + test("throws on non-2xx response", async () => { + const fetchMock = mock(() => Promise.resolve(mockFetchResponse({ error: "rate limited" }, 429))); + globalThis.fetch = fetchMock as typeof globalThis.fetch; + + await expect(submitVideoJob({ prompt: "test" }, auth)).rejects.toThrow("429"); + }); + + test("throws when request_id is missing", async () => { + const fetchMock = mock(() => Promise.resolve(mockFetchResponse({ foo: "bar" }))); + globalThis.fetch = fetchMock as typeof globalThis.fetch; + + await expect(submitVideoJob({ prompt: "test" }, auth)).rejects.toThrow("request_id"); + }); +}); + +describe("pollVideoJob", () => { + test("returns done status with video URL", async () => { + const fetchMock = mock(() => Promise.resolve(mockFetchResponse({ + status: "done", + video: { url: "https://cdn.x.ai/video.mp4" }, + }))); + globalThis.fetch = fetchMock as typeof globalThis.fetch; + + const result = await pollVideoJob("vid-123", auth); + expect(result.status).toBe("done"); + expect(result.videoUrl).toBe("https://cdn.x.ai/video.mp4"); + }); + + test("normalizes completed → done", async () => { + const fetchMock = mock(() => Promise.resolve(mockFetchResponse({ status: "completed" }))); + globalThis.fetch = fetchMock as typeof globalThis.fetch; + + const result = await pollVideoJob("vid-123", auth); + expect(result.status).toBe("done"); + }); + + test("normalizes error → failed", async () => { + const fetchMock = mock(() => Promise.resolve(mockFetchResponse({ state: "error" }))); + globalThis.fetch = fetchMock as typeof globalThis.fetch; + + const result = await pollVideoJob("vid-123", auth); + expect(result.status).toBe("failed"); + }); + + test("returns processing for unknown status", async () => { + const fetchMock = mock(() => Promise.resolve(mockFetchResponse({ status: "rendering" }))); + globalThis.fetch = fetchMock as typeof globalThis.fetch; + + const result = await pollVideoJob("vid-123", auth); + expect(result.status).toBe("processing"); + }); + + test("throws on non-2xx response", async () => { + const fetchMock = mock(() => Promise.resolve(mockFetchResponse({}, 401))); + globalThis.fetch = fetchMock as typeof globalThis.fetch; + + await expect(pollVideoJob("vid-123", auth)).rejects.toThrow("401"); + }); + + test("uses GET method on poll URL", async () => { + let capturedUrl: string | undefined; + let capturedMethod: string | undefined; + const fetchMock = mock((url: string, init: RequestInit) => { + capturedUrl = url; + capturedMethod = init.method; + return Promise.resolve(mockFetchResponse({ status: "processing" })); + }); + globalThis.fetch = fetchMock as typeof globalThis.fetch; + + await pollVideoJob("vid-789", auth); + expect(capturedUrl).toContain("/videos/vid-789"); + expect(capturedMethod).toBe("GET"); + }); + + test("encodes requestId in poll URL", async () => { + let capturedUrl: string | undefined; + const fetchMock = mock((url: string) => { + capturedUrl = url; + return Promise.resolve(mockFetchResponse({ status: "processing" })); + }); + globalThis.fetch = fetchMock as typeof globalThis.fetch; + + await pollVideoJob("req/with?special&chars", auth); + expect(capturedUrl).toContain(encodeURIComponent("req/with?special&chars")); + // Must NOT contain the raw special chars in the path + expect(capturedUrl).not.toMatch(/\/videos\/req\/with/); + }); +}); diff --git a/tests/windows-secret-acl.test.ts b/tests/windows-secret-acl.test.ts index e3a19b1d8..95871b44b 100644 --- a/tests/windows-secret-acl.test.ts +++ b/tests/windows-secret-acl.test.ts @@ -16,7 +16,9 @@ import { join } from "node:path"; import { hardenSecretDir, hardenSecretPath, + hardenSecretPathAsync, resetHardenedStateForTests, + setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests, setNowForTests, setPlatformForTests, @@ -475,3 +477,86 @@ describe("icacls failure paths (injected seams)", () => { expect(ownerHasExplicitAce).toBe(true); }); }); + +// --------------------------------------------------------------------------- +// Async harden (#612): same policy as sync, but yields via asyncIcaclsRunner. +// --------------------------------------------------------------------------- + +describe("async hardenSecretPath (issue #612)", () => { + const ok: IcaclsResult = { success: true, exitCode: 0, timedOut: false, stdout: "" }; + const timeout: IcaclsResult = { success: false, exitCode: null, timedOut: true, stdout: "" }; + const denied: IcaclsResult = { success: false, exitCode: 5, timedOut: false, stdout: "" }; + let warnings: string[] = []; + const realWarn = console.warn; + + beforeEach(() => { + setPlatformForTests("win32"); + resetHardenedStateForTests(); + process.env.USERNAME ??= "tester"; + warnings = []; + console.warn = (...args: unknown[]) => { warnings.push(args.join(" ")); }; + }); + + afterEach(() => { + setPlatformForTests(null); + setIcaclsRunnerForTests(null); + setAsyncIcaclsRunnerForTests(null); + setNowForTests(null); + resetHardenedStateForTests(); + console.warn = realWarn; + }); + + function secretFile(name = "secret.json"): string { + const filePath = join(testDir, name); + writeFileSync(filePath, "data", "utf-8"); + return filePath; + } + + test("async timeout soft-fails with the same policy as sync", async () => { + setAsyncIcaclsRunnerForTests(async () => timeout); + const result = await hardenSecretPathAsync(secretFile(), { required: true }); + expect(result.ok).toBe(false); + expect(result.diagnostics).toContain("ETIMEDOUT"); + expect(warnings.some(w => w.includes("continuing without NTFS ACL harden"))).toBe(true); + }); + + test("async permission failure still throws on required paths", async () => { + setAsyncIcaclsRunnerForTests(async () => denied); + await expect(hardenSecretPathAsync(secretFile(), { required: true })).rejects.toThrow(/EICACLS/); + }); + + test("timeoutMemoKey shares the timeout cache across distinct temp paths", async () => { + setAsyncIcaclsRunnerForTests(async () => timeout); + const dest = join(testDir, "responses-state.json"); + const tempA = join(testDir, "responses-state.json.ocx.1.1.tmp"); + const tempB = join(testDir, "responses-state.json.ocx.1.2.tmp"); + writeFileSync(tempA, "a", "utf-8"); + writeFileSync(tempB, "b", "utf-8"); + + const first = await hardenSecretPathAsync(tempA, { required: true, timeoutMemoKey: dest }); + expect(first.ok).toBe(false); + expect(first.diagnostics).toContain("ETIMEDOUT"); + + let calls = 0; + setAsyncIcaclsRunnerForTests(async () => { + calls += 1; + return timeout; + }); + const second = await hardenSecretPathAsync(tempB, { required: true, timeoutMemoKey: dest }); + expect(second.ok).toBe(false); + expect(second.diagnostics).toContain("skipped"); + expect(calls).toBe(0); // destination-keyed memo; not a parent-directory shortcut + }); + + test("async harden still grants owner before inheritance removal", async () => { + const steps: string[] = []; + setAsyncIcaclsRunnerForTests(async args => { + if (args.includes("/grant:r")) steps.push("grant-owner"); + else if (args.includes("/inheritance:r")) steps.push("remove-inheritance"); + else if (args.includes("/remove:g")) steps.push("remove-broad"); + return ok; + }); + expect(await hardenSecretPathAsync(secretFile(), { required: true })).toEqual({ ok: true }); + expect(steps).toEqual(["grant-owner", "remove-inheritance", "remove-broad"]); + }); +});